# Configuración de Postext

> Referencia completa de todas las opciones de configuración de composición de Postext

- Versión HTML: https://postext.dev/es/docs/configuration
- Última actualización: 2026-09-22
- Tiempo de lectura: 30 min
- Otros idiomas: [en](https://postext.dev/en/docs/configuration.md)

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

```ts
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](/es/docs/architecture).

## Índice

Esta referencia es larga. Estos son los bloques principales:

- [Página](#página) — tamaño, márgenes, rejilla base, marcas de corte.
- [Disposición](#disposición) — número de columnas, medianil, filetes.
- [Encabezados y pies](#encabezados-y-pies) — elementos de texto y línea por página con marcadores, paridad y alineación.
- [Texto de cuerpo](#texto-de-cuerpo) — tipografía, separación silábica, viudas y huérfanas.
- [Idioma del documento](#idioma-del-documento) — el `locale` de primer nivel: respaldo de la separación silábica, cadenas de continuación de tablas, lengua etiquetada en un PDF accesible.
- [Encabezados](#encabezados) — valores generales y sobrescrituras H1–H6.
- [Listas no ordenadas](#listas-no-ordenadas) y [Listas ordenadas](#listas-ordenadas) — viñetas, numeración, anidamiento.
- [Matemáticas](#matemáticas) — renderizado LaTeX, escala, color, márgenes.
- [Tipos de recurso](#tipos-de-recurso) — numeración tipada para figuras, tablas y tipos personalizados.
- [Estilo de tablas](#estilo-de-tablas) (con [estilos de tabla con nombre](#estilos-de-tabla-con-nombre)) y [Estilo de pies de recurso](#estilo-de-pies-de-recurso) — tipografía y decoración de las tablas-recurso y sus pies.
- [Estilo de diagramas](#estilo-de-diagramas) — recoloreado a una sola tinta de los diagramas SVG incrustados para impresión con tinta plana.
- [Estilos de párrafo](#estilos-de-párrafo) — estilos con nombre para contenedores `:::paragraphs`: bibliografías, glosarios, notas.
- [Estilos de aviso](#estilos-de-aviso) — notas, consejos y objetivos en caja para contenedores `:::callout`.
- [Partes](#partes) — páginas separadoras de parte para contenedores `:::part`: paridad, área de cuerpo, diseño de apertura, tipografía del cuerpo.
- [Estilos de encabezado](#estilos-de-encabezado) — estilos con nombre para encabezados `{style="…"}`: capítulos sin numerar, preliminares con sus propias cabeceras, geometría y paleta.
- [Índice de contenidos](#índice-de-contenidos) — lo que imprime `:::toc`: tipografía de las entradas, líneas de puntos, números de página, líneas de autores, filas de parte.
- [Unidades y colores](#unidades-y-colores) + [Paleta de colores](#paleta-de-colores) — `Dimension`, `ColorValue`, colores con nombre.
- [Fuentes personalizadas](#fuentes-personalizadas) — declarar familias tipográficas subidas por el usuario junto con Google Fonts.
- [Visor HTML](#visor-html) — ancho objetivo de columna y cortes para el backend HTML.
- [Generación de PDF (configuración)](#generación-de-pdf-configuración) — outlines, salida accesible (etiquetada) y espacio de color forzado para el backend PDF.
- [Depuración](#depuración) — superposiciones visuales y avisos de autoría para el editor.
- [Uso programático](#uso-programático) — `buildDocument`, resolvers, cachés.
- [Ejecutar la composición en un Web Worker](#ejecutar-la-composición-en-un-web-worker) — builds fuera del hilo principal con cancelación.
- [Integrar el visor HTML](#integrar-el-visor-html) y [Generación de PDF](#generación-de-pdf) — recetas completas.

## Página

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

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `sizePreset` | `PageSizePreset` | `'17x24'` | Tamaño de página predefinido. Establece `'custom'` para usar ancho/alto explícitos. |
| `width` | `Dimension` | `17 cm` | Ancho de página. Se toma de `sizePreset` cuando se omite; un valor explícito siempre prevalece (usa `sizePreset: 'custom'` para tamaños totalmente personalizados). |
| `height` | `Dimension` | `24 cm` | Alto de página. Se toma de `sizePreset` cuando se omite; un valor explícito siempre prevalece. |
| `margins` | `PageMargins` | `2 cm` todos los lados | Espacio 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. |
| `backgroundColor` | `ColorValue` | `transparent` | Color de fondo de la página. |
| `dpi` | `number` | `300` | Puntos por pulgada. Afecta a cómo se convierten las unidades físicas (cm, mm, in) a píxeles. |
| `cutLines` | `CutLinesConfig` | desactivado | Mostrar 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. |
| `baselineGrid` | `BaselineGridConfig` | desactivado | Superponer 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:

```json
{
  "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

| Preset | Ancho | Alto | Uso habitual |
| --- | --- | --- | --- |
| `'11x17'` | 11 cm | 17 cm | Libros de bolsillo |
| `'12x19'` | 12 cm | 19 cm | Formato rústica estándar |
| `'17x24'` | 17 cm | 24 cm | Libros técnicos, manuales |
| `'21x28'` | 21 cm | 28 cm | Revistas, informes (cercano a A4) |

> **Figura: Tamaños de página predefinidos**
> Cuatro tamaños de página predefinidos dibujados a escala proporcional: bolsillo 11x17, rústica 12x19, técnico 17x24 y cercano a A4 21x28 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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Si se dibuja la rejilla base. |
| `color` | `ColorValue` | `#cccccc` | Color de las líneas de la rejilla. |
| `lineWidth` | `Dimension` | `0.5 pt` | Grosor de las líneas de la rejilla. |

```ts
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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Si se expande el lienzo con sangrado y se dibujan las marcas de corte. |
| `bleed` | `Dimension` | `3 mm` | Área extra alrededor de la página usada como sangrado de impresión. |
| `markLength` | `Dimension` | `5 mm` | Longitud de cada marca de corte. |
| `markOffset` | `Dimension` | `3 mm` | Separación entre la esquina de la página y el inicio de la marca de corte. |
| `markWidth` | `Dimension` | `0.25 pt` | Grosor de las marcas de corte. |
| `color` | `ColorValue` | `#000000` | Color 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**).

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `format` | `'decimal' \| 'lower-roman' \| 'upper-roman' \| 'lower-alpha' \| 'upper-alpha'` | `'decimal'` | Estilo numérico utilizado para renderizar las etiquetas de página. |
| `startAt` | `number` | `1` | Valor 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 <code>{pageNumber}</code> 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.

> **Figura: Columnas, medianil y margen**
> Una 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.
>
> *Las columnas contienen el texto. Los medianiles las separan. Los márgenes enmarcan el contenido.*

> **Figura: Sistema de márgenes**
> Una página con márgenes independientes superior, derecho, inferior e izquierdo alrededor del área de contenido.
>
> *Cada lado de la página puede tener su propio margen.*

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `layoutType` | `'single' \| 'double' \| 'oneAndHalf'` | `'double'` | Disposición de columnas. Ver más abajo para detalles de cada tipo. |
| `gutterWidth` | `Dimension` | `0.75 cm` | Espacio horizontal entre columnas. Solo aplica a disposiciones multicolumna. |
| `sideColumnPercent` | `number` | `33` | Ancho 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'`. |
| `columnRule` | `ColumnRuleConfig` | desactivado | Filete vertical opcional trazado entre columnas. Ver más abajo. |
| `fitFiguresToPage` | `boolean` | `false` | Reduce 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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Si se dibuja el filete de columna. |
| `color` | `ColorValue` | `#cccccc` | Color del filete. |
| `lineWidth` | `Dimension` | `0.5 pt` | Grosor 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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `elements` | `HeaderFooterElement[]` | valores por defecto integrados cuando es `undefined`; `[]` los desactiva | 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.

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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `kind` | `'text'` | — | Discriminador. |
| `id` | `string` | — | Id estable, único dentro del bloque. Otros elementos se anclan a él con `anchor.to: '#id'`. El sandbox asigna uno al crearlo. |
| `content` | `string` | `''` | Plantilla. Admite los marcadores listados más abajo, además de `{attr.<clave>}` — un atributo escrito en la línea del H1 del capítulo actual (`# Título {author="I. Zango"}`). 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í. |
| `fontFamily` | `string` | `'EB Garamond'` | Familia tipográfica. |
| `fontSize` | `Dimension` | `8 pt` | Tamaño de fuente. |
| `fontWeight` | `number` | `400` | Grosor de fuente (100–900). |
| `italic` | `boolean` | `false` | Si se renderiza en cursiva. |
| `color` | `ColorValue` | `#000000` | Color 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. |
| `lineHeight` | `number` | `1.2` | Interlineado de las líneas envueltas, como múltiplo de `fontSize`. |
| `letterSpacing` | `Dimension` | `0` | Tracking: 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. |
| `box` | `ElementBoxStyle` | — | Fondo 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](https://postext.dev/es/docs/configuration#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. |
| `paragraphIndent` | `Dimension` | `0` | Sangrí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. |
| `hyphenate` | `boolean` | `false` | Cuando 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. |
| `marginFromBody` | `Dimension` | `6 pt` | Distancia absoluta entre el borde del elemento que mira al cuerpo y el borde del cuerpo. Independiente de otros elementos. Migrado a `placement.offset.y`. |
| `marginFromEdge` | `Dimension` | `0 pt` | Desplazamiento horizontal respecto al borde al que está alineado. Solo aplica cuando `align` es `'left'` o `'right'`. Migrado a `placement.offset.x`. |
| `placement` | `ElementPlacement` | derivado de `align` + `marginFromBody` + `marginFromEdge` | Posicionamiento 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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `kind` | `'rule'` | — | Discriminador. |
| `id` | `string` | — | Id 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. |
| `color` | `ColorValue` | `#000000` | Color del trazo. |
| `thickness` | `Dimension` | `0.5 pt` | Grosor de la línea. |
| `width` | `Dimension \| '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'`. |
| `marginFromBody` | `Dimension` | `6 pt` | Distancia absoluta entre el borde de la línea que mira al cuerpo y el borde del cuerpo. Independiente de otros elementos. |
| `marginFromEdge` | `Dimension` | `0 pt` | Desplazamiento 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). |
| `placement` | `ElementPlacement` | derivado de `align` + `marginFromBody` + `marginFromEdge` | Posicionamiento avanzado (ver [Posicionamiento de elementos](https://postext.dev/es/docs/configuration#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».

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `kind` | `'box'` | — | Discriminador. |
| `id` | `string` | — | Id 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.backgroundColor` | `ColorValue` | `transparent` | Color de relleno. Pon `transparent` para una caja solo de borde. |
| `style.borderColor` | `ColorValue` | `transparent` | Color del trazo. |
| `style.borderWidth` | `Dimension` | `0 pt` | Grosor del trazo. El trazo se pinta hacia el interior del rectángulo de la caja, así las dimensiones exteriores no varían. |
| `style.borderRadius` | `Dimension` | `0 pt` | Radio de esquina. Se acota a la mitad del lado más corto en tiempo de renderizado. |
| `placement` | `ElementPlacement` | — | Obligatorio. 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.

```ts
{
  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' } },
}
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `id` | `string` | — | Identificador estable; otros elementos pueden anclarse a él como `#id`. |
| `resourceId` | `string` | — | Id de un `Resource` de mapa de bits o SVG del documento. |
| `placement` | `ElementPlacement` | — | Anclaje, desplazamiento y tamaño (ver [Posicionamiento de elementos](https://postext.dev/es/docs/configuration#posicionamiento-de-elementos)). Un lado `'fill'` llega hasta el borde del contenedor. |
| `parity`, `pages` | como 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 <code>resourceImageUrl</code>.

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

```ts
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:

```json
{ "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.

> **Figura: Escala tipográfica**
> Jerarquía tipográfica desde H1 hasta texto pequeño, mostrando tamaños relativos de encabezados, texto de cuerpo y pies.
>
> *Una escala consistente mantiene la jerarquía legible de un vistazo.*

> **Figura: Escala de espaciado**
> Una escala de espaciado por pasos con valores crecientes usados en márgenes, rellenos y huecos.
>
> *Los pasos de espaciado construyen un ritmo predecible en la maquetación.*

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `fontFamily` | `string` | `'EB Garamond'` | Familia tipográfica para el cuerpo de texto. Cualquier fuente de Google Fonts, del sistema, o declarada como [fuente personalizada](#fuentes-personalizadas). |
| `fontSize` | `Dimension` | `8 pt` | Tamaño de fuente base para el cuerpo de texto. |
| `lineHeight` | `Dimension` | `1.5 em` | Espaciado vertical entre líneas. Las unidades relativas (em, rem) escalan con el tamaño de fuente. |
| `paragraphSpacing` | `boolean` | `false` | Cuando está activado, inserta una línea en blanco (igual al `lineHeight`) entre párrafos consecutivos, como hacen algunas editoriales. |
| `color` | `ColorValue` | `#000000` | Color del texto. |
| `boldColor` | `ColorValue` | Color 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. |
| `italicColor` | `ColorValue` | Color principal (`#295AA3`) | Color aplicado a los fragmentos en cursiva. Mismo enlace a la paleta que `boldColor`. |
| `referenceColor` | `ColorValue` | Color principal (`#295AA3`) | Color aplicado a las etiquetas de `:ref` en línea (referencias a recursos). Mismo enlace a la paleta que `boldColor`. |
| `referenceBold` | `boolean` | `true` | Renderizar las etiquetas de `:ref` en línea con la fuente en negrita. |
| `referenceItalic` | `boolean` | `false` | Renderizar 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). |
| `fontWeight` | `number` | `400` | Peso para el texto normal (100–900). |
| `boldFontWeight` | `number` | `700` | Peso para texto en negrita/strong (100–900). |
| `hyphenation` | `HyphenationConfig` | activada, `'en-us'` | Configuración de separación silábica automática. Ver más abajo. |
| `firstLineIndent` | `Dimension` | `1.5em` | Sangrí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). |
| `hangingIndent` | `boolean` | `false` | Cuando está activado, la sangría se aplica a todas las líneas excepto la primera (sangría francesa). |
| `indentAfterHeading` | `boolean` | `true` | Cuando 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. |
| `maxWordSpacing` | `number` | `2` | Lí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". |
| `minWordSpacing` | `number` | `0.6` | Límite inferior del espaciado entre palabras en texto justificado, como multiplicador del ancho del espacio normal. |
| `optimalLineBreaking` | `boolean` | `true` | Usar 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](/es/docs/justification). |

### 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 <a href="/es/docs/justification">Separación silábica y justificación</a> para una explicación detallada.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Si se permite la separación silábica. |
| `locale` | `HyphenationLocale` | `'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](#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.

```ts
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](/es/docs/justification) 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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `avoidOrphans` | `boolean` | `true` | Desaconsejar que un párrafo termine con menos de `orphanMinLines` líneas al principio de la siguiente columna. |
| `orphanMinLines` | `number` | `2` | Líneas mínimas requeridas al principio de la siguiente columna cuando un párrafo se parte. Solo activo cuando `avoidOrphans` es `true`. |
| `orphanPenalty` | `number` | `1000` | Demé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. |
| `avoidOrphansInLists` | `boolean` | `true` | Cuando 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`. |
| `avoidWidows` | `boolean` | `true` | Desaconsejar que un párrafo empiece con menos de `widowMinLines` líneas al final de la columna actual. |
| `widowMinLines` | `number` | `2` | Líneas mínimas requeridas al final de la columna actual cuando un párrafo se parte. Solo activo cuando `avoidWidows` es `true`. |
| `widowPenalty` | `number` | `1000` | Demérito añadido cuando se incumple la restricción de viudas. `0` desactiva la penalización. |
| `avoidWidowsInLists` | `boolean` | `true` | Cuando es `true`, los elementos de lista también reciben protección contra viudas. Solo efectivo si `avoidWidows` es `true`. |
| `avoidRunts` | `boolean` | `true` | Desaconsejar que los párrafos terminen con una última línea muy corta — un *runt*, p. ej. una única palabra corta aislada. |
| `runtMinCharacters` | `number` | `20` | Umbral 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?". |
| `runtPenalty` | `number` | `1000` | Penalizació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. |
| `avoidRuntsInLists` | `boolean` | `true` | Cuando es `true`, los elementos de lista también reciben la penalización de *runt*. Solo efectivo si `avoidRunts` es `true`. |
| `tightenRunts` | `boolean` | `true` | Cuando 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`. |
| `maxRuntTracking` | `number` | `10` | Tracking 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. |
| `slackWeight` | `number` | `10` | Peso 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. |
| `keepColonWithList` | `boolean` | `true` | Cuando 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

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `fontFamily` | `string` | `'Open Sans'` | Familia tipográfica para todos los encabezados. |
| `lineHeight` | `Dimension` | `1.2 em` | Altura de línea para encabezados. Más ajustada que el cuerpo de texto. |
| `color` | `ColorValue` | Color 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. |
| `fontWeight` | `number` | `700` | Peso de fuente para encabezados (100–900). |
| `marginTop` | `Dimension` | `1.5 em` | Espacio sobre los encabezados. |
| `marginBottom` | `Dimension` | `0.5 em` | Espacio bajo los encabezados. |
| `keepWithNext` | `boolean` | `true` | Cuando 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. |
| `snapToGrid` | `boolean` | `true` | Si 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. |
| `balancing` | `ColumnBalancingConfig` | activado | Equilibrado 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:

0. **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.
1. **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.
2. **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.
3. **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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Si se equilibran los finales de columna. |
| `maxLinesPerHeading` | `number` | `4` | Número máximo de líneas de rejilla adicionales que pueden añadirse sobre un mismo encabezado. |
| `stretchAfterLists` | `boolean` | `true` | Permite líneas de rejilla adicionales donde termina una lista, cuando los encabezados no pueden absorber todo el hueco. |
| `maxLinesAfterList` | `number` | `1` | Número máximo de líneas de rejilla adicionales tras el final de una misma lista. |
| `stretchAfterFloats` | `boolean` | `true` | Permite 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. |
| `maxLinesAfterFloat` | `number` | `1` | Número máximo de líneas de rejilla adicionales bajo un mismo flotante superior. |
| `looseParagraphs` | `boolean` | `true` | Ú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`. |
| `maxLooseParagraphs` | `number` | `2` | Cuántos párrafos de una misma columna corta pueden ganar una línea, los más largos primero. |
| `trackParagraphs` | `boolean` | `true` | Cuando 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. |
| `maxTracking` | `number` | `10` | Límite de ese tracking, en milésimas de eme por carácter (10 = 0,01 em). |
| `trailing` | `boolean` | `true` | Nivela 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. |
| `beforeSpan` | `boolean` | `true` | Nivela 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.

| Nivel | Tamaño de fuente por defecto | `breakBefore` por defecto |
| --- | --- | --- |
| H1 | `18 pt` | `{ enabled: true, parity: 'always-odd' }` |
| H2 | `15 pt` | `{ enabled: false, parity: 'any' }` |
| H3 | `12 pt` | `{ enabled: false, parity: 'any' }` |
| H4 | `10 pt` | `{ enabled: false, parity: 'any' }` |
| H5 | `9 pt` | `{ enabled: false, parity: 'any' }` |
| H6 | `8 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:

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `italic` | `boolean` | `false` | Renderiza 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. |
| `numberingTemplate` | `string` | `''` | Plantilla del número automático del nivel. Un token `{1}` … `{6}` imprime el contador acumulado de ese nivel de encabezado, opcionalmente formateado con un sufijo — `{1:I}` romanos en mayúscula, `{1:i}` romanos en minúscula, `{1:A}` / `{1:a}` alfabético, `{1:01}` con cero a la izquierda — y el resto del texto es literal (`'Capítulo {1}. '`, `'{1}.{2}'`; 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 `{number}` de una ranura de diseño avanzado (donde el prefijo no se antepone) y se imprime en el índice de contenidos. |
| `breakBefore` | `HeadingBreakBeforeConfig` | H1: `{ 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. |

```ts
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

| Valor | Comportamiento |
| --- | --- |
| `'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.

| Propiedad | Tipo | Predeterminado | Descripció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. |
| `advancedDesign` | `HeadingAdvancedDesignConfig` | `{ enabled: false, slot: { elements: [] } }` | Ranura de diseño de composición libre para este nivel. Cuando está `enabled`, los elementos de la ranura componen la apertura. Utiliza `{titleText}` dentro de un elemento de texto para renderizar el texto del título; `{number}`, `{numberRoman}`, etc. para insertar el número del encabezado. |
| `advancedDesign.minHeight` | `Dimension` | — | Altura 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:

```json
{
  "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

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `fontFamily` | `string` | hereda `bodyText.fontFamily` | Fuente del texto de los elementos. |
| `color` | `ColorValue` | Color principal (`#295AA3`) | Color del texto y de las viñetas de los elementos. Enlazado a la entrada `main-color` de la paleta por defecto. |
| `fontWeight` | `number` | `700` | Peso del texto de los elementos (100–900). Las viñetas heredan este peso salvo que se sobrescriba por nivel. |
| `italic` | `boolean` | `false` | Renderiza el texto de los elementos en cursiva. |
| `bulletChar` | `string` | `'•'` | Glifo utilizado como viñeta. |
| `bulletFontSize` | `Dimension` | `1 em` | Tamaño del glifo de la viñeta. Las unidades relativas escalan con el tamaño del cuerpo de texto. |
| `gap` | `Dimension` | `0.5 em` | Espacio horizontal entre la viñeta y el texto del elemento. |
| `indent` | `Dimension` | `0 em` | Sangrí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). |
| `bulletVerticalOffset` | `Dimension` | `0 em` | Ajuste fino vertical de la viñeta. Valores negativos la suben; positivos la bajan. |
| `marginTop` / `marginBottom` | `Dimension` | `1.5 em` | Espacio antes y después del bloque de lista. |
| `itemSpacing` | `Dimension` | `0 em` | Espacio vertical extra entre elementos, añadido sobre la altura de línea. |
| `hangingIndent` | `boolean` | `true` | Cuando 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). |
| `levels` | `UnorderedListLevelConfig[]` | — | 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:

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `taskCheckboxChar` | `string` | `'☐'` | Glifo para tareas no marcadas. |
| `taskCheckedChar` | `string` | `'☑'` | Glifo para tareas completadas. |
| `taskCompletedStrikethrough` | `boolean` | `true` | Dibuja una línea tachando el texto de las tareas completadas. |
| `taskCompletedColor` | `ColorValue` | hereda el color del elemento | Color 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:

| Propiedad | Tipo | Descripción |
| --- | --- | --- |
| `bulletChar` | `string` | Glifo de viñeta para esta profundidad. |
| `fontFamily` | `string` | Fuente del texto en esta profundidad. |
| `fontSize` | `Dimension` | Tamaño del glifo de viñeta en esta profundidad. |
| `color` | `ColorValue` | Color del texto del elemento. |
| `fontWeight` | `number` | Peso del texto del elemento. |
| `italic` | `boolean` | Activa la cursiva. |
| `indent` | `Dimension` | Sangría explícita de la viñeta en esta profundidad. Ver la regla de encadenamiento a continuación. |
| `verticalOffset` | `Dimension` | Ajuste 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.

```ts
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

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `fontFamily` | `string` | hereda `bodyText.fontFamily` | Fuente del texto y del marcador numérico. |
| `color` | `ColorValue` | Color principal (`#295AA3`) | Color del texto y del marcador numérico. Enlazado a la entrada `main-color` de la paleta por defecto. |
| `fontWeight` | `number` | `700` | Peso del texto y del marcador numérico (100–900). |
| `italic` | `boolean` | `false` | Renderiza el texto en cursiva. |
| `numberFormat` | `OrderedListNumberFormat` | `'arabic'` | Estilo de numeración: `'arabic'`, `'lower-alpha'`, `'upper-alpha'`, `'lower-roman'`, `'upper-roman'`. |
| `separator` | `string` | `'.'` | Carácter situado entre el número y el texto — normalmente `'.'` o `')'`. |
| `separatorFontFamily` | `string` | hereda `fontFamily` | Fuente 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. |
| `separatorFontWeight` | `number` | hereda `fontWeight` | Peso del separador (100–900). |
| `separatorItalic` | `boolean` | hereda `italic` | Renderiza el separador en cursiva. |
| `separatorColor` | `ColorValue` | hereda `color` | Color del separador. Se respetan las referencias a la paleta. |
| `separatorGap` | `Dimension` | `0 em` | Espacio entre el número y el separador. El texto del elemento sigue empezando `gap` después del separador. |
| `numberFontSize` | `Dimension` | `1 em` | Tamaño del marcador numérico. |
| `gap` | `Dimension` | `0.5 em` | Espacio horizontal entre el número y el texto del elemento. |
| `indent` | `Dimension` | `0 em` | Sangrí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. |
| `numberVerticalOffset` | `Dimension` | `0 em` | Ajuste fino vertical del marcador numérico. |
| `marginTop` / `marginBottom` | `Dimension` | `1.5 em` | Espacio antes y después del bloque de lista. |
| `itemSpacing` | `Dimension` | `0 em` | Espacio vertical extra entre elementos. |
| `hangingIndent` | `boolean` | `true` | Las líneas envueltas se alinean con el primer carácter de texto, no bajo el número. |
| `levels` | `OrderedListLevelConfig[]` | — | 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.

```ts
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.

```ts
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.
}
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Cuando 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. |
| `fontSizeScale` | `number` | `1.0` | Multiplicador 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. |
| `color` | `ColorValue` | hereda del cuerpo | Color 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. |
| `marginTop` | `Dimension` | `0.8em` | Espacio por encima de una fórmula en bloque. Se ignora para fórmulas en línea. |
| `marginBottom` | `Dimension` | `0.8em` | Espacio 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. |

```ts
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:

```ts
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](/es/docs/document-format#fórmulas-matemáticas).

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

```ts
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', … }]
```

```ts
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`.

| Propiedad | Tipo | Descripción |
| --- | --- | --- |
| `id` | `string` | Identificador 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**. |
| `name` | `string` | Nombre singular. Lo usa la referencia en línea con `style="full"` (p. ej. `Figura 1.7`). |
| `namePlural` | `string` (opcional) | Nombre plural, para etiquetas de la interfaz y listas de recursos. |
| `shortLabel` | `string` | Abreviatura compacta que usa el estilo de referencia en línea por defecto (p. ej. `Fig. 1.7`). |
| `numberingTemplate` | `string` | Plantilla del número calculado. Ver **Tokens de plantilla** más abajo. Las formas habituales son `{h1}.{n}` (ámbito de capítulo, p. ej. `2.3`) y `{n}` (un único conteo continuo). |
| `resetOn` | `ResourceCounterReset` | `'never'` da un conteo continuo para todo el documento; `'h1'`..`'h6'` reinician el contador `{n}` 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. `{h1}.{n}` con `resetOn: 'h1'`. |
| `counterFormat` | `ResourceCounterFormat` | Cómo se renderiza el contador `{n}`: 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 (`{h1}`…) se renderizan siempre como decimales. |
| `captionPrefix` | `string` | Texto que se antepone al pie de figura/tabla. El número calculado sigue al prefijo — un pie se renderiza como `{captionPrefix} {número}. {texto del pie}`, p. ej. **Figura 1.7. El plano original.** |
| `defaultPlacement` | `ResourcePlacement` (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](https://postext.dev/es/docs/configuration#numeración-y-referencias) más abajo para la cadena de resolución y [Formato del documento › Recursos](/es/docs/document-format#recursos) para lo que hace cada valor, incluidos los recursos girados. |
| `captionStyle` | `CaptionStyleConfig` (opcional) | Sobrescritura parcial del [estilo de pies](https://postext.dev/es/docs/configuration#estilo-de-pies-de-recurso) 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](#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.

| Plantilla | Con `h1 = 2`, contador = 3 | Notas |
| --- | --- | --- |
| `{n}` | `3` | Un único conteo continuo. Combínalo con `resetOn: 'never'`. |
| `{h1}.{n}` | `2.3` | Ámbito de capítulo. Combínalo con `resetOn: 'h1'`. |
| `{h1}.{h2}.{n}` | `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](/es/docs/document-format#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](#estilos-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.

```ts
const config: PostextConfig = {
  tableStyle: {
    headerBold: true,
    headerBackground: { hex: '#f0f0f0', model: 'hex' },
    borders: true,
    borderWidth: { value: 0.75, unit: 'pt' },
  },
};
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `bodyFontFamily` | `string` | fuente del cuerpo | Familia tipográfica de las celdas de cuerpo. |
| `bodyFontSize` | `Dimension` | tamaño del cuerpo | Tamaño de fuente de las celdas de cuerpo. |
| `bodyColor` | `ColorValue` | color del cuerpo | Color del texto de las celdas de cuerpo. |
| `headerFontFamily` | `string` | fuente del cuerpo | Familia tipográfica de las celdas de cabecera. |
| `headerFontSize` | `Dimension` | tamaño del cuerpo | Tamaño de fuente de las celdas de cabecera. |
| `headerColor` | `ColorValue` | color del cuerpo | Color del texto de las celdas de cabecera. |
| `headerBold` | `boolean` | `true` | Renderizar las celdas de cabecera en negrita. |
| `headerItalic` | `boolean` | `false` | Renderizar las celdas de cabecera en cursiva. |
| `headerBackgroundEnabled` | `boolean` | `true` | Pintar un relleno tras la fila de cabecera. |
| `headerBackground` | `ColorValue` | `#f0f0f0` | Color de relleno de la fila de cabecera. |
| `bodyBackgroundEnabled` | `boolean` | `false` | Pintar un relleno tras las filas de cuerpo. |
| `bodyBackground` | `ColorValue` | `#ffffff` | Color de relleno de las filas de cuerpo (solo se pinta cuando está activado). |
| `borders` | `boolean` | `true` | Dibujar los bordes de las celdas. |
| `borderColor` | `ColorValue` | color del cuerpo | Color del trazo de los bordes. |
| `borderWidth` | `Dimension` | `0.75pt` | Grosor del trazo de los bordes (≈1px a 96 DPI; escala con los DPI de la página). |
| `cellPadding` | `Dimension` | `0.375em` | Relleno 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. |
| `borderRadius` | `Dimension` | `0` | Radio 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. |
| `continuedSuffix` | `string` | `'(cont.)'` | Se añade, en cursiva, al pie de cada parte continuada de una tabla dividida. |
| `continuesMarkerEnabled` | `boolean` | `true` | Coloca un indicador bajo cada parte que continúa en la página siguiente. |
| `continuesMarker` | `string` | `'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](https://postext.dev/es/docs/configuration#estilo-de-pies-de-recurso)). 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.

```ts
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](#tipos-de-recurso)).

```ts
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' },
  },
};
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `fontFamily` | `string` | fuente del cuerpo | Familia tipográfica del pie (etiqueta y descripción). |
| `fontSize` | `Dimension` | tamaño del cuerpo | Tamaño de fuente del pie (etiqueta y descripción). |
| `color` | `ColorValue` | color del cuerpo | Color del texto de la descripción. |
| `align` | `'left' \| 'center' \| 'right'` | `'left'` | Alineación horizontal del pie bajo el recurso. |
| `gap` | `Dimension` | `0.75em` | Separación vertical entre el recurso y su pie. |
| `labelBold` | `boolean` | `true` | Renderizar la etiqueta numerada (p. ej. `Figura 1`) en negrita. |
| `labelItalic` | `boolean` | `false` | Renderizar la etiqueta numerada en cursiva. |
| `labelColor` | `ColorValue` | `color` del pie | Color de la etiqueta numerada. |
| `descriptionItalic` | `boolean` | `false` | Renderizar 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. |
| `backgroundEnabled` | `boolean` | `false` | Pintar 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. |
| `background` | `ColorValue` | color principal de la paleta | Color de relleno de la barra (solo se pinta cuando está activada). |
| `padding` | `Dimension` | `0.35em` | Relleno interior entre el borde de la barra y el texto del pie. Se ignora si la barra está desactivada. |
| `note` | `object` | — | Estilo 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.

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `note.fontSize` | `Dimension` | 0.85 × tamaño del pie | Tamaño de fuente de la nota. |
| `note.color` | `ColorValue` | `color` del pie | Color del texto de la nota. |
| `note.italic` | `boolean` | `false` | Renderizar la nota en cursiva. |
| `note.gap` | `Dimension` | `0.35em` | Separació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.

```ts
const config: PostextConfig = {
  diagramStyle: {
    singleInk: true,
    inkColor: { hex: '#295AA3', model: 'hex' },
  },
};
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `singleInk` | `boolean` | `false` | Recolorear todos los diagramas SVG incrustados a matices de una única tinta. |
| `inkColor` | `ColorValue` | Color 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.

```ts
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`:

```ts
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.

```ts
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' },
    },
  ],
};
```

```md
## Referencias

:::paragraphs{style="bibliography"}
Knuth, D. E. (1984). *The TeXbook*. Addison-Wesley.

Bringhurst, R. (2004). *The Elements of Typographic Style*. Hartley & Marks.
:::
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `id` | `string` | obligatorio | Identificador al que hace referencia `:::paragraphs{style="…"}`. |
| `name` | `string` | `id` | Nombre legible, solo para interfaces de edición. |
| `fontFamily` | `string` | fuente del cuerpo | Familia tipográfica. Los pesos (regular y negrita) siguen al texto de cuerpo. |
| `fontSize` | `Dimension` | tamaño del cuerpo | Tamaño de fuente. |
| `lineHeight` | `Dimension` | interlineado del cuerpo | Interlineado. `em`/`rem` son relativos al tamaño del propio estilo, así que un `1.5em` heredado se estrecha junto con un tamaño menor. |
| `color` | `ColorValue` | color del cuerpo | Color del texto. Las negritas y cursivas conservan los colores de énfasis del cuerpo. |
| `textAlign` | `'left' \| 'justify' \| 'center' \| 'right'` | alineación del cuerpo | Alineación horizontal. `'center'` y `'right'` dejan cada línea en bandera por el otro lado — una dedicatoria, una firma. |
| `boldColor` | `ColorValue` | `bodyText.boldColor` | Color de las negritas (una lista de autores con los nombres en el color de la casa). |
| `hyphenation` | `boolean` | separación del cuerpo | Separar sílabas al justificar (usa el idioma del documento). |
| `firstLineIndent` | `Dimension` | sangría del cuerpo | Sangría de la primera línea. Se ignora cuando `hangingIndent` no es cero. |
| `hangingIndent` | `Dimension` | `0` | Sangría aplicada a todas las líneas salvo la primera — la forma clásica de bibliografías y glosarios. |
| `spaceBetween` | `Dimension` | `0` | Separación vertical entre párrafos consecutivos dentro del contenedor. Con `0` las entradas quedan pegadas. |
| `marginTop` | `Dimension` | `0` | Espacio sobre el primer párrafo del contenedor. Colapsa con el espaciado ya pendiente y desaparece al inicio de una columna, como cualquier otro margen. |
| `marginBottom` | `Dimension` | `0` | Espacio 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.

```ts
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.

```ts
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,
    },
  ],
};
```

```md
Clasifica: :chip[pila] :chip[cable] :chip[interruptor]

Pulsa :chip[Ctrl]{style="key"} + :chip[C]{style="key"}.
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `id` | `string` | obligatorio | Identificador que se usa en `:chip[…]{style="…"}`. |
| `name` | `string` | `id` | Nombre legible, solo para interfaces de edición. |
| `backgroundEnabled` | `boolean` | `true` | Pinta el relleno de la caja. |
| `background` | `ColorValue` | `#e8eef7` | Relleno de la caja (vinculable a la paleta). |
| `borderColor` | `ColorValue` | color principal de la paleta | Color del contorno. |
| `borderWidth` | `Dimension` | `0.5pt` | Grosor del contorno; `0` no dibuja ninguno. Se traza por dentro del borde de la caja. |
| `borderRadius` | `Dimension` | `0.3em` | Radio de las esquinas, limitado a la mitad de la altura de la caja (un valor grande da una píldora). |
| `paddingX` | `Dimension` | `0.3em` | Espacio entre el contorno y el texto, a izquierda y derecha. Forma parte del avance del chip. |
| `paddingY` | `Dimension` | `0.1em` | Espacio por encima y por debajo de la banda del texto. Se pinta fuera de la caja de línea: nunca cambia el interlineado. |
| `fontFamily` | `string` | texto que lo rodea | Familia del texto del chip. Los pesos siguen al texto que lo rodea. |
| `fontSize` | `Dimension` | texto que lo rodea | Cuerpo del texto del chip; `em` es relativo al texto que lo rodea. |
| `color` | `ColorValue` | texto que lo rodea | Color del texto del chip. Sin fijar, las negritas y cursivas conservan sus colores de énfasis. |
| `bold` | `boolean` | `false` | Compone el texto del chip en negrita, además de sus propias marcas. |
| `italic` | `boolean` | `false` | Compone el texto del chip en cursiva, además de sus propias marcas. |
| `gap` | `Dimension` | `0.25em` | Espacio 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.

```ts
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.

```ts
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' } },
    },
  ],
};
```

```md
:::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.
:::
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `id` | `string` | — | Identificador que selecciona `:::callout{type="…"}`. Una valla con un `type` desconocido o ausente usa el primer estilo configurado (el sandbox avisa de los tipos desconocidos). |
| `name` | `string` | `id` | Nombre legible (solo para la interfaz del editor). |
| `title` | `string` | `''` | 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`. |
| `fixed` | `{ anchor?, offset? }` | `{ anchor: { to: 'container', edge: 'bottom-left' } }` | Posició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`). |
| `floatBarrier` | `boolean` | `false` | Convierte 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` / `background` | `boolean` / `ColorValue` | `true` / `#f4f4f4` | Relleno de la caja. |
| `border` | `{ enabled, color, width }` | `false`, `#cccccc`, `0.5pt` | Contorno de la caja. |
| `borderRadius` | `Dimension` | `0` | Radio de las esquinas del fondo / borde. |
| `padding` | `{ top, right, bottom, left }` | `0.75em` cada uno | Margen 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 principal | Franja 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.5em` | Tipografí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 `bodyText` | Tipografí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 `unorderedLists` | Tipografí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. |
| `columnGap` | `Dimension` | `1.5em` | Separación entre las columnas de un grupo `:::columns` dentro de la caja (véase la sección del contenedor). |
| `marginTop` / `marginBottom` | `Dimension` | `0.75em` / `0.75em` | Espacio sobre la caja (se funde con el margen del bloque anterior) y espacio mínimo bajo ella (el espacio exacto con `snapToGrid: false`). |
| `snapToGrid` | `boolean` | `true` | Con `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. |
| `keepTogether` | `boolean` | `true` | Con `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`. |
| `splitMinLines` | `number` | `2` | Mí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.

```ts
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.

```ts
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' } },
  },
};
```

```md
:::part{number="I" title="Fundamentos"}
1. El farol y sus partes
2. Recortar la mecha
3. Leer el tiempo
:::

# El farol y sus partes
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `page` | `boolean` | `true` | Si 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.parity` | `HeadingBreakParity` | `'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](https://postext.dev/es/docs/configuration#saltar-antes) de los encabezados: un blanco insertado para alcanzar la paridad pertenece a la parte (su `{partTitle}` ya resuelve a la parte nueva); el separador obligatorio de `'always-*'` pertenece al contenido anterior. |
| `breakAfter.enabled` | `boolean` | `true` | Pasa 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.parity` | `HeadingBreakParity` | `'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. |
| `margins` | `PageMargins` | má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. |
| `design` | `DesignSlot` | vacío | Diseñ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 `{number} {titleText}` por defecto con la tipografía del H1 en la esquina superior izquierda del área de cuerpo. |
| `versoDesign` | `DesignSlot` | vacío | Diseñ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`, `textAlign` | como en `bodyText` | heredan de `bodyText` | Tipografí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.bulletColor` | `ColorValue` | `unorderedLists.color` | Color de las viñetas de las listas no ordenadas de dentro de la parte. |
| `bodyStyle.numberColor` | `ColorValue` | `orderedLists.color` | Color 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.unorderedLists` | `UnorderedListsConfig` | — | Sobrescrituras 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.orderedLists` | `OrderedListsConfig` | — | Sobrescrituras 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.

```ts
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.

```ts
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' },
    },
  ],
};
```

```md
# Prólogo {style="preliminar"}
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `id` | `string` | — | Identificador al que se refiere `{style="…"}` en una línea de encabezado. Un id desconocido deja el encabezado tal cual. |
| `name` | `string` | `id` | Nombre legible (solo para la interfaz del editor). |
| `numbered` | `boolean` | `true` | Si el encabezado cuenta: avanza el contador de su nivel (los números de `numberingTemplate`, el `{h1}` de la numeración de recursos), el ordinal de capítulo que hay tras `{chapterNumber}` 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 `{chapterNumber}` queda vacío en sus páginas. |
| `toc` | `boolean` | `true` | Si `:::toc` lista el encabezado. Un encabezado lo sobrescribe con `{toc="false"}` / `{toc="true"}`. |
| campos de nivel | como en `headings.levels[]` | los valores del nivel | `fontFamily`, `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`, `footer` | `DesignSlot` | los del documento | Cabeceras 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. |
| `margins` | `PageMargins` | má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`. |
| `layout` | `LayoutConfig` | `layout` | Disposició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. |
| `bodyStyle` | `PartsBodyStyleConfig` | hereda `bodyText` | Tipografía de los párrafos, citas y listas de la sección — los mismos campos que [parts.bodyStyle](https://postext.dev/es/docs/configuration#partes). |
| `palette` | `Record<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.

```ts
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](/es/docs/document-format#toc)). 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`.

```ts
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}' */] },
    },
  },
};
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `levels` | `TocLevelConfig[]` | nivel 1 | Niveles 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. |
| `unnumbered` | `TocEntryStyleConfig` | — | Sobrescrituras para los encabezados cuyo estilo declara `numbered: false` (un prólogo): no imprimen número y empiezan a ras en el `indent` del nivel. |
| `pageNumber` | objeto | fuente del nivel 1, peso del cuerpo | `fontFamily`, `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. |
| `leader` | objeto | `{ enabled: true, char: '.', gap: 0.5em }` | `char` 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. |
| `subtitle` | objeto | `{ enabled: false, attr: 'author' }` | Una 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.enabled` | `boolean` | `true` | Si los separadores de parte reciben una fila. |
| `parts.breakBefore` | `boolean` | `false` | Abre 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.design` | `DesignSlot` | vacío | Diseño de la fila; su contenedor es la fila (anchura de columna × `height`). Marcadores: `{number}`, `{numberDecimal}`, `{numberRoman}`…, `{titleText}` y `{pageNumber}` (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 `{number} {titleText}` y el número de página con la tipografía de las entradas de nivel 1. |
| `parts.height`, `marginTop`, `marginBottom` | `Dimension` | dos líneas de cuerpo, `0`, `0` | Altura 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:

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

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

**Unidades relativas** — `em`, `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:

```ts
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

```ts
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.

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```ts
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'>;
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `maxCharsPerLine` | `number` | `70` | Medida 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. |
| `columnGap` | `number` | `50` | Espacio horizontal, en píxeles CSS, entre columnas cuando el visor está en modo multi-columna. Se ignora en modo columna única. |
| `optimalLineBreaking` | `boolean` | `false` | Activa 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. |
| `overrides` | `HtmlViewerOverrides` | — | Una 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. |

```ts
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:

```ts
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](#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.

```ts
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).
}
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `outlines` | `boolean` | `true` | Emite 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). |
| `forceColorSpace` | `boolean` | `false` | Cuando 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`. |
| `accessible` | `boolean` | `true` | Genera 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. |

```ts
pdfGeneration: {
  outlines: true,
  accessible: true,
  forceColorSpace: true,
  colorSpace: 'cmyk',
}
```

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

```ts
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](#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.

| Propiedad | Tipo | Descripción |
| --- | --- | --- |
| `cursorSync` | `SyncIndicatorConfig` | Cursor reflejado en la composición renderizada — ver [Superposiciones visuales](https://postext.dev/es/docs/configuration#superposiciones-visuales). |
| `selectionSync` | `SyncIndicatorConfig` | Selección de la fuente resaltada en la página — ver [Superposiciones visuales](https://postext.dev/es/docs/configuration#superposiciones-visuales). |
| `looseLineHighlight` | `LooseLineHighlightConfig` | Superposición sobre las líneas justificadas flojas — ver [Superposiciones visuales](https://postext.dev/es/docs/configuration#superposiciones-visuales). |
| `pageNegative` | `{ enabled: boolean }` | Negativo de alto contraste de la página — ver [Superposiciones visuales](https://postext.dev/es/docs/configuration#superposiciones-visuales). |
| `warnings` | `WarningsToggleConfig` | Un booleano por cada clase de aviso de autoría que muestra el editor — ver [Avisos](https://postext.dev/es/docs/configuration#avisos). |

### Superposiciones visuales

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `cursorSync.enabled` | `boolean` | `true` | Muestra un cursor en la composición renderizada que refleja la posición del cursor en la fuente. |
| `cursorSync.color` | `ColorValue` | `#2563eb` | Color de ese cursor. |
| `selectionSync.enabled` | `boolean` | `true` | Resalta el rango renderizado que coincide con la selección en la fuente. |
| `selectionSync.color` | `ColorValue` | `#fde04780` | Color del resaltado — un amarillo translúcido por defecto. |
| `looseLineHighlight.enabled` | `boolean` | `false` | Pinta una superposición sobre las líneas justificadas cuyo espaciado entre palabras supera `threshold` veces el ancho del espacio normal. |
| `looseLineHighlight.color` | `ColorValue` | `#ff000040` | Color de esa superposición. |
| `looseLineHighlight.threshold` | `number` | `3` | Multiplicador del ancho del espacio normal a partir del cual una línea justificada cuenta como floja. El aviso `looseLines` usa el mismo umbral. |
| `pageNegative.enabled` | `boolean` | `false` | Renderiza 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 }`.

```ts
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.

```ts
interface WarningsToggleConfig {
  missingFont?: boolean;
  looseLines?: boolean;
  headingHierarchy?: boolean;
  consecutiveHeadings?: boolean;
  listAfterHeading?: boolean;
  designIssues?: boolean;
}
```

| Propiedad | Tipo | Por defecto | Descripción |
| --- | --- | --- | --- |
| `missingFont` | `boolean` | `true` | Avisa 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. |
| `looseLines` | `boolean` | `true` | Avisa 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. |
| `headingHierarchy` | `boolean` | `true` | Avisa 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. |
| `consecutiveHeadings` | `boolean` | `false` | Avisa 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. |
| `listAfterHeading` | `boolean` | `false` | Avisa 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. |
| `designIssues` | `boolean` | `true` | Avisa 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 `{titleText}`. |

```ts
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](#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](#ejecutar-la-composición-en-un-web-worker), que llama a `buildDocument` dentro de un hilo worker dedicado con los mismos argumentos.

```ts
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:

```ts
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`:

```ts
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.

> **Ejemplo ejecutable: Postext · renderizar una página como imagen** — Compone un documento markdown con postext y rasteriza su primera página a un canvas / PNG. ([código](https://github.com/drnachio/postext/tree/main/docs/examples/render-page))

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

```ts
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](#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](#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:

```ts
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](#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:

```ts
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:

```ts
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](/es/docs/document-format) 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:

```ts
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 `ArrayBuffer`s 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 `ArrayBuffer`s 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

```ts
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:

```tsx
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:

```ts
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.

```ts
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.

> **Ejemplo ejecutable: Postext · renderizar un documento a HTML** — Compone un documento markdown con postext y renderízalo a una cadena HTML. ([código](https://github.com/drnachio/postext/tree/main/docs/examples/render-html))

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

```tsx
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 incremental** — `renderToHtmlIndexed` 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](#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](#ejecutar-la-composición-en-un-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](#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

```bash
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

```ts
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:

```ts
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:

```ts
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:

```tsx
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.

> **Ejemplo ejecutable: Postext · generar un PDF en el navegador** — Compone un documento markdown con postext y renderízalo a PDF con postext-pdf. ([código](https://github.com/drnachio/postext/tree/main/docs/examples/render-pdf))

### 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](#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](#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](/es/docs/sandbox#exportar-e-importar) lo exporta e importa y el [skill para agentes](/es/docs/skill) 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](/es/docs/sandbox#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:

```ts
import { openBundle } from 'postext';

const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });

bundle.chapters;   // [{ title, file, markdown }, …] en el orden del libro
bundle.config;     // PostextConfig, lista para buildDocument
bundle.resources;  // Resource[]
bundle.files;      // Map<ruta, Uint8Array>: todos los archivos del paquete
```

| Campo | Qué contiene |
| --- | --- |
| `manifest` | El `preset.json` ya validado. |
| `id`, `name`, `description` | Tomados del manifiesto. |
| `locale`, `locales` | El idioma en que se leyó el contenido, y todos los idiomas que trae un paquete bilingüe. `options.locale` elige uno: primero la etiqueta exacta, luego el idioma base, luego el idioma propio del paquete. |
| `chapters` | `{ title, file, markdown }` por capítulo. Un capítulo sin título en el manifiesto toma el texto de su primer encabezado `#`. |
| `config` | La paleta de colores por defecto y los tipos de recurso 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. |
| `resources` | Los recursos, con los pies del idioma elegido. Un tamaño que falte en el manifiesto se lee del archivo. |
| `fonts` | Una entrada por variante: `{ family, weight, style, format, file, bytes }`. |
| `files` | Todos los archivos del zip, por su ruta. |
| `thumbnail`, `canvasScope` | La ruta de la portada, y cómo pide el paquete que se muestre. |
| `warnings` | Problemas que no impiden abrirlo: un archivo de fuente no admitido, un máster de impresión que falta. |

El **`fileId` de cada archivo es su ruta dentro del paquete**. `resource.svg.fileId`, `resource.bitmap.fileId` y el `fileId` de cada variante de `customFonts` se buscan directamente en `bundle.files`. `openBundle` lanza un error si los bytes no son un zip, si no hay un `preset.json` válido (en la raíz o bajo una única carpeta) o si falta un archivo que el manifiesto nombra.

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

```ts
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.

> **Ejemplo ejecutable: Postext · abrir un paquete .postext** — Abrir un archivo .postext con postext, componer el libro y renderizarlo en canvas y PDF. ([código](https://github.com/drnachio/postext/tree/main/docs/examples/open-bundle))

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

```ts
import { createBundle } from 'postext';

const { bytes, manifest, warnings } = await createBundle({
  name: 'El farol',
  locale: 'es',
  chapters: [
    { markdown: '# Anochecer\n\nSe dibuja en :ref{id="farol"}.' },
    { title: 'Noche', markdown: '# Noche\n\n…' },
  ],
  config,
  resources: [{
    id: 'farol', typeId: 'figure', kind: 'svg', caption: 'El farol.',
    svg: { fileId: 'farol.svg', width: 240, height: 150 },
    createdAt: 0, updatedAt: 0,
  }],
  files: { 'farol.svg': svgMarkup, 'garamond-regular': fontBytes },
});
```

| Entrada | Significado |
| --- | --- |
| `name`, `id`, `description`, `locale` | Los metadatos del manifiesto. Por defecto `id` es un slug de `name`. |
| `chapters` o `markdown` | El libro, un `{ title?, markdown }` por capítulo, o un documento único. |
| `config` | La `PostextConfig`. Los valores iguales a los de por defecto no se escriben en el manifiesto. |
| `resources` | Los recursos. Una imagen nombra sus datos con `bitmap.fileId` / `svg.fileId` (y `svg.pdfFileId` para un máster de impresión). |
| `files` | Los datos por `fileId` (un objeto o un `Map`): las imágenes que citan los recursos y los archivos de fuente que citan las variantes de `config.customFonts`. Cada valor puede ser un `Uint8Array`, un `ArrayBuffer`, un `Blob` o un texto (el código de un SVG). |
| `thumbnail` | `{ data, mime }`: una portada (PNG, JPEG, WebP, GIF o SVG). |
| `canvasScope` | `'book'` pide a los visores que compongan el libro entero como un solo lienzo. |

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

> **Ejemplo ejecutable: Postext · crear un paquete .postext** — Escribir un archivo .postext con createBundle de postext, descargarlo y volver a abrirlo. ([código](https://github.com/drnachio/postext/tree/main/docs/examples/create-bundle))

### 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](/es/docs/skill), o diseña un libro en el [Sandbox](/es/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`.
