# Espaciado uniforme en columnas estrechas y justificadas

> Corte Knuth–Plass con los espacios entre 0,8 y 1,6 veces el normal, junto a un control voraz con sus líneas flojas, solas y cortas marcadas desde el VDT.

- Versión HTML: https://postext.dev/es/cookbook/justification-lab
- Receta N.º 014 · Texto y tipografía · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Revistas y fanzines
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/justification-lab/es/p01.webp?v=490a7c43), [2](https://postext.dev/cookbook/justification-lab/es/p02.webp?v=490a7c43), [3](https://postext.dev/cookbook/justification-lab/es/p03.webp?v=490a7c43)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/justification-lab.md)

## Lo que vas a componer

El número 12 de *Galerada*, revista de un taller tipográfico, en A5 y a dos columnas de unos cuarenta caracteres, donde cada línea justificada reparte la holgura entre cinco o seis espacios. La apertura dibuja sobre una banda grafito el modelo de Knuth y Plass: cajas de palabras unidas por muelles amarillos de cola que se estiran hasta llenar la medida. El ensayo explica por qué se aflojan las columnas estrechas e intercala en monoespaciada los ajustes de cada regla. Al pie de la página 3, un banco de pruebas compone un párrafo en bandera, justificado sin partir palabras y justificado partiéndolas. El pen compone además una segunda edición con el corte voraz, sin protecciones de viudas ni huérfanas, y, antes del número, muestra juntas las dos páginas 2, como en la tarjeta: las líneas flojas, en amarillo fosforito; las solas y las cortas, señaladas en el margen.

**Esta receta responde a:**

- ¿Cómo consigo una justificación uniforme en columnas estrechas, con la separación silábica de cada idioma?
- ¿Cómo evito viudas, huérfanas y últimas líneas de una sola palabra, y mantengo cada título con su texto?
- ¿Por qué una línea sale en bandera o demasiado estirada (URL, compuestos largos, palabras largas en celdas)?
- ¿Cómo averiguo qué falla en mi documento (avisos, desbordamientos, composición que no converge)?

## La respuesta corta

```js
// script.js, líneas 39–57
// Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by
// default. A narrow column also needs a tighter fence round the glue, in multiples of a
// normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any
// hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far.
const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 };
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
  ...FENCES,
};
// Hyphenation follows the document's locale, by exact code (gotcha: hyphenation-locales):
const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
// The control: first-fit breaking, which sets each line once and moves on, with the widow and
// orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure;
// a fresh object on every call, like config() itself, because the engine caches resolved
// configs by identity (gotcha: config-cache-identity).
const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false };
const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } });
```

## Ingredientes

**Enseña**

- [Corte de líneas óptimo (Knuth–Plass)](https://postext.dev/es/docs/justification.md#knuth-plass-ver-el-párrafo-completo): Corta cada párrafo en su conjunto para igualar el espacio entre palabras, dentro de los límites fijados, en lugar de llenar línea a línea.
- [Viudas, huérfanas y líneas cortas](https://postext.dev/es/docs/configuration.md#huérfanas-viudas-runts-y-reglas-de-cohesión): Evita las líneas sueltas en la cabeza y el pie de las columnas y las últimas líneas de una sola palabra; mantiene cada título con su texto, y el párrafo que acaba en dos puntos, con la lista que presenta.
- [Avisos y diagnóstico](https://postext.dev/es/docs/configuration.md#depuración): Avisos de composición, problemas de análisis y convergencia, las ayudas Resaltar líneas flojas y Negativo de página, y el panel Revisión del Sandbox.

**También usa**

- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-soportados)
- [Sangrías, alineación y separación de párrafos](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns)
- [Recuadros anidados](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`parseMarkdownWithIssues`](https://postext.dev/es/docs/configuration.md#parseo), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

- Petrona (OFL-1.1), Bricolage Grotesque (OFL-1.1), Source Code Pro (OFL-1.1)

## Elaboración

### 1 · Estrecha los límites y conserva las protecciones

El código de este paso es [la respuesta corta](#la-respuesta-corta) de arriba. [Knuth–Plass](/es/docs/justification#knuth-plass-ver-el-párrafo-completo), la separación silábica, las penalizaciones de viudas, huérfanas y líneas cortas y los títulos unidos a su texto vienen activados por defecto, así que la edición publicada solo estrecha los [límites del espaciado](/es/docs/justification#límites-de-espaciado-entre-palabras) a 0,8 y 1,6 veces un espacio normal (por defecto son 0,6 y 2). Una línea que pasa del límite superior cuesta más que cualquier guion o línea corta, así que el algoritmo prueba antes otros cortes; sin esos dos límites, este ensayo compone ocho líneas por encima de 1,6. La partición depende de `locale` y de su código exacto: `'es'`, `'fr'` y `'de'` tienen sus propios patrones de TeX, mientras que `'es-ES'` se parte como inglés estadounidense sin ningún aviso.

### 2 · Marca las líneas flojas desde el árbol de composición

```js
// script.js, líneas 303–345
// debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the
// VDT: justified lines carry justifiedSpaceRatio; a paragraph cut by a column is two blocks.
function marks(doc, page) {
  const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) =>
    b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify');
  const out = [];
  for (const column of page.columns) {
    for (const b of column.blocks.filter((x) => body.includes(x))) {
      const parts = body.filter((o) => o.contentIndex === b.contentIndex);
      b.lines.forEach((line) => {
        const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column };
        if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
          out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it ragged
        }
        if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay):
          out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head
        } else if (line.isLastLine && !/\s/.test(line.text.trim())) {
          out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line
        }
      });
    }
  }
  return out;
}
const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' },
  es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } });
function paintMarks(canvas, list, scale) {
  const ctx = canvas.getContext('2d');
  ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
  for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin
    const loose = m.kind === 'loose';
    ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through
    ctx.fillStyle = loose ? palette.marker : palette.graphite;
    if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; }
    ctx.font = `600 ${m.h * 0.48}px "${MONO}"`;
    const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5;
    const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35;
    ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8);
    ctx.fillStyle = palette.marker;
    ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7);
  }
  ctx.setTransform(1, 0, 0, 1, 0, 0);
}
```

Solo el Sandbox aplica `debug.looseLineHighlight`, y `doc.warnings` nunca menciona una línea floja, así que `marks()` lee directamente el [VDT](/es/docs/justification#depuración-de-líneas-flojas). Cada línea justificada lleva su `justifiedSpaceRatio`, un párrafo partido entre dos columnas vuelve como un bloque por fragmento, y una línea de más de 3× no tiene proporción, porque el motor [la compone en bandera](/es/docs/justification#líneas-que-el-algoritmo-no-puede-llenar) y la marca con `line.ragged`. Con cuarenta caracteres por línea, los límites no bastan para que todas queden por debajo de 1,6; mientras se ajustaba el texto del ensayo, las marcas señalaron qué frases había que reescribir.

### 3 · Pon el control junto a la página publicada

```js
// script.js, líneas 349–383
function compare(pairs) {
  document.head.insertAdjacentHTML('beforeend', `<style>
    #compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px;
      font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; }
    #compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff;
      margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; }
    #compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; }
    #compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
    #compare canvas { width: 100%; display: block; }
    #compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; }
    #compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0;
      font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; }
    @media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`);
  const section = Object.assign(document.createElement('section'), { id: 'compare' });
  section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2',
    es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({
    en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`;
  for (const [name, doc] of pairs) {
    const page = doc.pages[1];
    const list = marks(doc, page); // one walk per edition: the counts and the paint share it
    const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length;
    const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · `
      + `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · `
      + `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`;
    const figure = document.createElement('figure');
    figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`;
    const canvas = figure.appendChild(document.createElement('canvas'));
    canvas.setAttribute('role', 'img');
    canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`);
    renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width });
    paintMarks(canvas, list, 1000 / page.width);
    section.querySelector('.pair').append(figure);
  }
  document.getElementById('pages').before(section); // #pages: the desk showPages() builds
}
```

Las dos ediciones salen del mismo diseño, así que cualquier diferencia entre las dos páginas 2 se debe al algoritmo de corte y a sus protecciones. En el texto español, el control voraz deja 26 de sus 108 líneas justificadas por encima de 1,6, y seis de ellas pasan de 2; la edición de Knuth–Plass mantiene las 105 entre 0,80 y 1,59. En la página 2 del control, además, un párrafo acaba en una línea corta que solo lleva el final de una palabra partida.

### 4 · Dale a cada ajuste su propio recuadro

```js
// script.js, líneas 109–132
// A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3"
// counts a nested box as one block (gotcha: callout-columns). The bench floats to a page foot.
const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them
const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP),
  padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) },
  titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'),
    gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN
  body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } });
const calloutStyles = [
  { id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'),
    columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped
    padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) },
    titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) },
    body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left',
      firstLineIndent: pt(0) } },
  slip('ragged', { textAlign: 'left' }), // never hyphenated (gotcha: ragged-no-hyphenation)
  slip('unhyphenated', { hyphenation: false }), // justified, like the body text
  slip('justified', {}), // justified and hyphenated: the body text's own settings
  { id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config
    stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') },
    padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) },
    body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'),
      firstLineIndent: pt(0) } },
];
```

Un recuadro compone todas las columnas de un grupo [`:::columns`](/es/docs/document-format#columns) con un único estilo de texto, así que cada ficha es un recuadro anidado con su propio `body`, y `breaks="3"` apila en la primera columna la ficha en bandera y la que no parte palabras. La ficha en bandera no necesita `hyphenation: false`, porque el texto en bandera nunca se parte. El banco flota al pie de la página 3 con `placement: 'bottom'`, y su relleno inferior repite el marco de 4 mm porque el `marginBottom` de la última ficha se pierde al final del recuadro.

### 5 · Dibuja el modelo en la apertura

```js
// script.js, líneas 61–89
const BAND = 104; // mm from the trim's top edge to the band's foot
const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height
const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits
const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap',
  placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) },
    size: { width: mm(MEASURE) } }, ...extra });
const opener = () => ({ // a function: the diagram's constants are defined further down
  enabled: true,
  slot: { elements: [
    // The band box reserves the opener's height, and the H1's default marginBottom adds a line
    // of white: the body starts on the second grid line under the band. The diagram could not
    // reserve it, as images never count (gotcha: opener-image-no-reserve).
    { kind: 'box', id: 'band', style: { backgroundColor: col('graphite') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)),
    text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both
      { fontWeight: 800, lineHeight: 1.02 }), // a multiple (gotcha: design-lineheight-multiple)
    text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19,
      { italic: true, lineHeight: 1.3 }),
    { kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page',
      edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) },
    size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } },
    // An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text.
    ...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze',
      INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })),
  ] },
});
```

El diagrama es un [elemento de imagen](/es/docs/configuration#elementos-de-imagen) del diseño del H1. Como figura sería un flotante `top` a todo el ancho, y un flotante `top` citado en la página 1 abre la página 2. La banda es una caja, y las cajas cuentan para la altura que reserva una apertura, pero las imágenes no, así que el texto empieza bajo la banda sin `minHeight`, una línea más abajo por el `marginBottom` que el H1 trae por defecto. Los rótulos son texto de diseño colocado sobre las coordenadas del propio dibujo, porque un SVG pintado como imagen no puede usar las fuentes web de la página.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/justification-lab

### script.js

```js
// ═══ Postext Cookbook · Nº 014 · Justification lab ════════════════════════════════
// https://postext.dev/en/cookbook/justification-lab
// Code: MIT · Text: original (CC BY 4.0) · Diagram: generated in code (CC BY 4.0)
// Fonts: Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A type journal's essay set twice from one design, Knuth–Plass and greedy: the two page 2s
// side by side with their loose lines marked from the layout tree, then the published pages.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseMarkdownWithIssues,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'justification-lab';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Graphite, paper, one highlighter yellow; col() writes hex too (gotcha: palette-skips-designs).
const palette = {
  ink: '#1f2124', // text: a graphite near-black
  graphite: '#2e3136', // the accent: the opener band, the bench box, folios
  marker: '#ffe14d', // highlighter yellow: glue and loose lines, never type on white
  haze: '#c3c7cc', // type on graphite: the standfirst, the diagram's labels
  muted: '#66686c', // running heads, settings lines, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'defaults', value: { hex: palette.graphite, model: 'hex' } }, // no blue
];
const [TEXT, DISPLAY, MONO] = ['Petrona', 'Bricolage Grotesque', 'Source Code Pro'];
const [BODY, LEAD] = [9.5, 13]; // pt: body size and leading, the grid both columns share
// mm: an A5 trim, mirrored margins and a narrow gutter: two columns of about 40 characters
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [148, 210, 20, 20, 16, 13, 5];
const MEASURE = TRIM_W - INNER - OUTER; // mm: 119, the text width the opener aligns to
const caps = (size, weight = 500) => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: weight,
  letterSpacing: pt(size * 0.16), textTransform: 'uppercase' }); // tracked mono labels

// #region answer: one design, two line breakers: Knuth–Plass inside fences, greedy without
// Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by
// default. A narrow column also needs a tighter fence round the glue, in multiples of a
// normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any
// hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far.
const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 };
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
  ...FENCES,
};
// Hyphenation follows the document's locale, by exact code (gotcha: hyphenation-locales):
const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
// The control: first-fit breaking, which sets each line once and moves on, with the widow and
// orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure;
// a fresh object on every call, like config() itself, because the engine caches resolved
// configs by identity (gotcha: config-cache-identity).
const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false };
const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } });
// #endregion

// #region opener: the title on a graphite band, with the diagram drawn in as an image element
const BAND = 104; // mm from the trim's top edge to the band's foot
const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height
const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits
const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap',
  placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) },
    size: { width: mm(MEASURE) } }, ...extra });
const opener = () => ({ // a function: the diagram's constants are defined further down
  enabled: true,
  slot: { elements: [
    // The band box reserves the opener's height, and the H1's default marginBottom adds a line
    // of white: the body starts on the second grid line under the band. The diagram could not
    // reserve it, as images never count (gotcha: opener-image-no-reserve).
    { kind: 'box', id: 'band', style: { backgroundColor: col('graphite') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)),
    text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both
      { fontWeight: 800, lineHeight: 1.02 }), // a multiple (gotcha: design-lineheight-multiple)
    text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19,
      { italic: true, lineHeight: 1.3 }),
    { kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page',
      edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) },
    size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } },
    // An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text.
    ...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze',
      INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })),
  ] },
});
// #endregion

const HEAD_Y = 11; // mm from the top (bottom) edge: running heads in the margin, folios outside
const head = (id, content, parity, edge, x, extra) => ({ ...text(id, content, MONO, 7.5,
  'muted', 0, 0, caps(7.5)), parity, pages: 'body', align: edge.split('-')[1], // left | right
  placement: { anchor: { to: 'page', edge },
    offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEAD_Y : -HEAD_Y) } }, ...extra });
const folio = { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(8), letterSpacing: pt(0),
  color: col('graphite') };
const header = { elements: [
  head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('v-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8),
  head('r-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -OUTER,
  { ...folio, pages: 'opener' })] }; // the opener's folio drops to its foot

// #region bench: three slips in a 2 × 2 grid, each a nested box with a body style of its own
// A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3"
// counts a nested box as one block (gotcha: callout-columns). The bench floats to a page foot.
const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them
const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP),
  padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) },
  titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'),
    gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN
  body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } });
const calloutStyles = [
  { id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'),
    columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped
    padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) },
    titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) },
    body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left',
      firstLineIndent: pt(0) } },
  slip('ragged', { textAlign: 'left' }), // never hyphenated (gotcha: ragged-no-hyphenation)
  slip('unhyphenated', { hyphenation: false }), // justified, like the body text
  slip('justified', {}), // justified and hyphenated: the body text's own settings
  { id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config
    stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') },
    padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) },
    body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'),
      firstLineIndent: pt(0) } },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale, colorPalette,
  page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP),
    bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText,
  headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'),
    levels: [ // restated: a headings object drops the H1 break (gotcha: headings-drop-h1-break)
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ] },
  paragraphStyles: [
    { id: 'colophon', fontFamily: MONO, fontSize: pt(6.6), lineHeight: pt(9), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  calloutStyles,
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Galerada"
subtitle: "Cuadernos de composición · n.º 12"
author: "Galerada"
---

# El problema de los ríos {kicker="Galerada · n.º 12 · Justificación" standfirst="En una columna estrecha y justificada, los espacios entre palabras son lo primero que se abre. Cómo un algoritmo que sopesa el párrafo entero mantiene uniforme el gris, y los ocho ajustes que lo gobiernan."}

Sostén una página de periódico con el brazo extendido y entorna los ojos. En una buena columna, el texto se vuelve un gris uniforme. En una mala, unos canales pálidos la recorren de arriba abajo, de un hueco al de la línea siguiente. Los tipógrafos los llaman ríos, o calles. Se forman cuando los espacios se abren en varias líneas a la vez y coinciden en vertical, y nada los abre tanto como una medida estrecha.

En una columna de cuarenta caracteres, cada línea tiene cinco o seis espacios entre palabras. Si una palabra larga pasa a la línea siguiente, esos pocos espacios se reparten todo su ancho, y cada uno puede llegar al doble. Si la columna mide el doble, lo reparten el doble de espacios y cada uno crece la mitad.

## Cajas y cola

En 1981, Donald E. Knuth y Michael F. Plass describieron un párrafo tal como todavía lo ve TeX. Las palabras son cajas de ancho fijo. Los espacios son cola: tienen un ancho natural y un límite a lo que pueden estirarse o encogerse. Las penalizaciones marcan los puntos donde una línea puede terminar, y su precio: acabar en guion cuesta algo; entre dos palabras, nada. El dibujo que abre el artículo muestra una línea en esos términos, medida y compuesta, con su cola estirada hasta llenar la medida.

Después, el algoritmo pone precio a cada línea según lo que se ha movido su cola, lo eleva al cubo, para que una línea muy floja cueste más que varias un poco flojas, y le suma las penalizaciones. De todos los cortes posibles del párrafo, se queda con el de menor coste total, sumadas todas sus líneas.

## Línea a línea o en bloque

El método antiguo, el del primer ajuste, sobrevive en la web. Llena cada línea con las palabras que caben y pasa a la siguiente sin volver atrás, así que meter ahora una palabra corta más puede dejar la línea siguiente con un hueco imposible de cerrar. El del párrafo entero afloja un poco una línea para ahorrarle un hueco a la siguiente. Repetido a lo largo de una columna, ese intercambio deja menos líneas flojas que el primer ajuste y, con ellas, menos huecos que se alineen en ríos.

## Límites para la cola

La cola tiene dos límites: cada espacio puede encogerse hasta el 80 % de su ancho natural y crecer hasta el 160 %. Si se estrechan, el algoritmo se queda sin maneras de llenar la línea; si se aflojan, el ojo ve los huecos. Pasado el límite superior, cada estirón cuesta más que un guion o una línea final corta, así que el algoritmo prueba antes otra salida: partir una palabra o llevarla a otra línea.

:::callout{type="settings"}
minWordSpacing: 0.8

maxWordSpacing: 1.6
:::

:::callout{type="bench" title="Banco de pruebas · un párrafo, tres ajustes"}
:::columns{count=2 breaks="3"}
:::callout{type="ragged" title="bandera · textAlign: 'left'"}
Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan.
:::

:::callout{type="unhyphenated" title="justificado · hyphenation: false"}
Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan.
:::

:::callout{type="justified" title="justificado · hyphenation: true"}
Si no se parten las palabras, unos pocos espacios cargan con todo lo que una medida estrecha no admite. Partirlas permite cortar también dentro de una palabra, y la holgura se reparte en ajustes tan pequeños que no se notan.
:::

Las mismas palabras, compuestas de tres maneras en una medida algo más estrecha que estas columnas. En bandera solo se cortan entre palabras, haya partición o no. Justificadas y sin partir, unos pocos espacios cargan con toda la holgura. Con partición, los espacios se igualan a cambio de unos cuantos guiones.
:::
:::

## Partir palabras

La separación silábica le da al algoritmo más puntos donde terminar una línea, y en una medida estrecha es imprescindible. Postext usa los patrones de TeX del idioma del documento, indicado con un código exacto: *es* en esta edición y *en-us* en la inglesa. Con un código sin patrones, como *es-ES*, recurre al inglés estadounidense sin avisar. Los patrones solo actúan en texto justificado: en bandera, las líneas se cortan entre palabras y una columna estrecha queda muy desigual.

## Viudas y huérfanas

Una primera línea sola al pie de una columna y una última línea sola en la cabeza de la siguiente son la pareja que los manuales prohíben, aunque nadie se ha puesto de acuerdo en cuál es la viuda. Postext lo resuelve por posición: avoidWidows actúa al pie de cada columna, y avoidOrphans, en su cabeza.

:::callout{type="settings"}
widowPenalty: 1000

orphanPenalty: 1000

slackWeight: 10
:::

Cada regla es un precio que el algoritmo compara con el blanco que dejaría cumplirla. Una línea sola cuesta su penalización, y las líneas vacías que un corte dejaría al pie de la columna cuestan diez veces el cuadrado de su número. Gana lo más barato.

Una línea corta es una última línea que no llega a sostenerse sola: una palabra, o el final de una, bajo un párrafo lleno. Postext le pone precio dentro del algoritmo de corte, así que gana el juego de cortes que baje una segunda palabra, si los límites lo permiten. Si no lo permite ninguno, compone el párrafo con una línea menos: aprieta los espacios y, si hace falta, las letras, sin pasar de diez milésimas de cuadratín.

:::callout{type="settings"}
runtMinCharacters: 20

runtPenalty: 1000

maxRuntTracking: 10
:::

## Lo que el ojo perdona

Una palabra larga aún puede hacer que una línea de una columna estrecha quede algo más floja que sus vecinas, y un párrafo que ha de acabar en algún sitio a veces acaba en una línea corta. El algoritmo puede pasar de una línea a otra el espacio que sobra, pero no quitarlo. Junto a una palabra larga, lo reparte entre cinco líneas en vez de dejarlo entero en un solo hueco, que se vería incluso con el brazo extendido.

Una línea que aún se abre más de la cuenta queda para el corrector, que casi siempre la cierra con cambiar una palabra de la frase por otra más corta o más larga.

:::paragraphs{style="colophon"}
Galerada se compone en Petrona, Bricolage Grotesque y Source Code Pro (SIL Open Font License). Texto y diagrama: originales, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook


// #region art: boxes, glue and penalties: one line as measured and as set, and its resource
const resources = [{ id: 'diagram', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'diagram.svg', width: DIAGRAM.w * 10, height: DIAGRAM.h * 10 }, // 10 px a mm
  altText: t({ en: 'A line of word boxes whose last word overruns the measure; then the same '
    + 'line hyphenated, its springs stretched until it fills the measure exactly.',
  es: 'Una línea de cajas cuya última palabra rebasa la medida; después, la misma línea '
    + 'partida, con los muelles estirados hasta llenar la medida justa.' }) }];
// Word boxes in mm; the long last word may break at a hyphenation penalty after its first part.
const WORDS = [[14], [8], [17], [6.5], [13.5], [10], [16.7, 16]];
const [ROW_H, GLUE, HYPHEN, ROWS] = [4.4, 3.6, 2, [7, 20.5]]; // mm; ROWS: the rows' tops
const SET = WORDS.reduce((sum, w) => sum + w[0], HYPHEN); // the set line: boxes to the hyphen
const STRETCH = (MEASURE - SET) / (WORDS.length - 1) / GLUE; // what fills the measure: ×1.45
const R = (v) => Math.round(v * 100) / 100;
function spring(x, y, w) { // a zigzag of eight turns: glue, stretched or at rest
  const pts = Array.from({ length: 9 }, (_, i) => `${R(x + (i * w) / 8)} ${R(y + (i % 2 ? -1 : 1)
    * 0.9)}`);
  return `<path d="M${R(x)} ${R(y)}L${pts.join('L')}L${R(x + w)} ${R(y)}" fill="none" `
    + `stroke="${palette.marker}" stroke-width="0.45" stroke-linejoin="round"/>`;
}
function row(y, glue, broken) { // one line of boxes; `broken`: set up to the penalty
  let [x, out] = [0, ''];
  const box = (w, h = ROW_H) => { out += `<rect x="${R(x)}" y="${R(y + (ROW_H - h) / 2)}" `
    + `width="${R(w)}" height="${h}" rx="0.5" fill="${palette.paper}"/>`; x += w; };
  WORDS.forEach((word, i) => {
    if (i) { out += spring(x, y + ROW_H / 2, glue); x += glue; }
    box(word[0]);
    if (word.length === 1) return;
    // The penalty: a flagged break inside the word, marked by a yellow wedge. Taken, it sets
    // a hyphen (a short bar); passed over, the rest of the word runs on past the measure.
    out += `<path d="M${R(x - 1.1)} ${y - 2.6}h2.2l-1.1 1.9z" fill="${palette.marker}"/>`;
    if (broken) box(HYPHEN, 1); else box(word[1]);
  });
  return out;
}
function diagram() {
  const measure = `<path d="M${MEASURE - 0.2} 3V${ROWS[1] + ROW_H + 2}" ` // stops over the legend
    + `stroke="${palette.haze}" stroke-width="0.4" stroke-dasharray="0.8 0.8"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${DIAGRAM.w * 10}" `
    + `height="${DIAGRAM.h * 10}" viewBox="0 0 ${DIAGRAM.w} ${DIAGRAM.h}">`
    + `${row(ROWS[0], GLUE, false)}${row(ROWS[1], GLUE * STRETCH, true)}${measure}</svg>`;
}
function diagramLabels() { // [id, text, x, y] in mm from the diagram's top-left corner
  const k = STRETCH.toFixed(2).replace('.', t({ en: '.', es: ',' }));
  return [
    ['l-natural', t({ en: 'As measured: the word overruns',
      es: 'Medida natural: la palabra no cabe' }), 0, ROWS[0] - 5.5],
    ['l-set', t({ en: `As set: hyphenated, each space ×${k}`,
      es: `Compuesta: partida, cada espacio ×${k}` }), 0, ROWS[1] - 5.5],
    ['l-legend', t({ en: 'Box: a word · glue: a space · penalty: a break · dashes: the measure',
      es: 'Caja: palabra · cola: espacio · penalización: corte · trazos: la medida' }),
    0, ROWS[1] + ROW_H + 3.5],
  ];
}
// #endregion

// #region marks: the highlighter: loose lines, lone lines and runts read from the layout tree
// debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the
// VDT: justified lines carry justifiedSpaceRatio; a paragraph cut by a column is two blocks.
function marks(doc, page) {
  const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) =>
    b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify');
  const out = [];
  for (const column of page.columns) {
    for (const b of column.blocks.filter((x) => body.includes(x))) {
      const parts = body.filter((o) => o.contentIndex === b.contentIndex);
      b.lines.forEach((line) => {
        const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column };
        if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
          out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it ragged
        }
        if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay):
          out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head
        } else if (line.isLastLine && !/\s/.test(line.text.trim())) {
          out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line
        }
      });
    }
  }
  return out;
}
const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' },
  es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } });
function paintMarks(canvas, list, scale) {
  const ctx = canvas.getContext('2d');
  ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
  for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin
    const loose = m.kind === 'loose';
    ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through
    ctx.fillStyle = loose ? palette.marker : palette.graphite;
    if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; }
    ctx.font = `600 ${m.h * 0.48}px "${MONO}"`;
    const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5;
    const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35;
    ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8);
    ctx.fillStyle = palette.marker;
    ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7);
  }
  ctx.setTransform(1, 0, 0, 1, 0, 0);
}
// #endregion

// #region compare: the control beside the published page, each with its count of marks
function compare(pairs) {
  document.head.insertAdjacentHTML('beforeend', `<style>
    #compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px;
      font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; }
    #compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff;
      margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; }
    #compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; }
    #compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
    #compare canvas { width: 100%; display: block; }
    #compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; }
    #compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0;
      font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; }
    @media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`);
  const section = Object.assign(document.createElement('section'), { id: 'compare' });
  section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2',
    es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({
    en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`;
  for (const [name, doc] of pairs) {
    const page = doc.pages[1];
    const list = marks(doc, page); // one walk per edition: the counts and the paint share it
    const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length;
    const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · `
      + `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · `
      + `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`;
    const figure = document.createElement('figure');
    figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`;
    const canvas = figure.appendChild(document.createElement('canvas'));
    canvas.setAttribute('role', 'img');
    canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`);
    renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width });
    paintMarks(canvas, list, 1000 / page.width);
    section.querySelector('.pair').append(figure);
  }
  document.getElementById('pages').before(section); // #pages: the desk showPages() builds
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages and the comparison paint, loaded before the build (gotcha: fonts-first).
const FONTS = { Petrona: ['400', '400i', '700', '700i'], 'Bricolage Grotesque': ['800'],
  'Source Code Pro': ['400', '500', '600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('diagram.svg', diagram());
const build = (cfg) => buildWithFonts(() => buildDocument({ markdown, resources }, cfg()),
  markdown);
const greedy = await build(control); // first: the control, for the comparison only
const doc = await build(config); // last: the published pages
showPages(doc, { title: t({ en: 'Justification lab', es: 'Laboratorio de justificación' }) });
compare([[t({ en: 'Greedy, no guards', es: 'Voraz, sin protecciones' }), greedy],
  ['Knuth–Plass', doc]]);
// The engine's own report: parse issues (a ::: left open) and layout warnings, never loose lines.
const { issues } = parseMarkdownWithIssues(markdown);
kitStatus(t({ en: `${doc.pages.length} pages · parse issues ${issues.length} · layout warnings `,
  es: `${doc.pages.length} páginas · problemas de análisis ${issues.length} · avisos ` })
  + (doc.warnings?.length ?? 0));

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Parte un texto en alemán

Los patrones de partición se eligen por código exacto, así que un texto alemán necesita `'de'`.

```diff
-const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
+const locale = 'de'; // German patterns; 'de-DE' would hyphenate as English
```

### Marca solo las peores líneas

Sube el umbral de 1,6 a 2 y el resaltador marca solo las líneas cuyos espacios pasan del doble.

```diff
-        if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
+        if (line.justifiedSpaceRatio > 2 || line.ragged) {
```

## Errores frecuentes

- **avoidWidows vigila el pie de la columna, y avoidOrphans, su cabeza.** Postext da nombre propio a las dos líneas solas: avoidWidows (widowMinLines, widowPenalty) evita que la primera línea de un párrafo quede sola al pie de una columna, y avoidOrphans (orphanMinLines, orphanPenalty), que la última quede sola en la cabeza de la siguiente. Muchos manuales de estilo usan los dos nombres al revés, así que elige el ajuste por el lugar donde actúa. Los dos vienen activados y funcionan como penalizaciones: la composición compara cada una con las líneas vacías que dejaría respetarla.
- **En el texto en bandera no hay separación silábica.** La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida.
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **La mayoría de los avisos solo existen en el Sandbox.** Los ids, estilos y directivas desconocidos, las fuentes que faltan y las líneas flojas los comprueba el Sandbox, no el motor: un pen solo recibe doc.warnings y parseMarkdownWithIssues. Un estilo desconocido se sustituye sin aviso por otro y una directiva desconocida se imprime como texto, así que revisa tus ids.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **:::columns solo funciona dentro de un recuadro y no se parte.** :::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **Un flotante 'top' nunca cae en la página que lo cita.** Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita.
- **El texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Aviso de maquetación: Línea floja** (`looseLine`). Una línea justificada estira sus espacios por encima del umbral, casi siempre por una palabra larga, una URL o una medida estrecha. Solución: Activa la separación silábica en el idioma correcto, ensancha la medida, reformula o compón ese pasaje en bandera. ([Documentación](https://postext.dev/es/docs/justification.md#depuración-de-líneas-flojas))

- Un recuadro que queda al final de una columna baja hasta el pie cuando se equilibran las columnas, lejos del párrafo al que pertenece. Ajusta el texto para que la columna se llene, como hizo falta con el recuadro de ajustes bajo «Límites para la cola».
- En postext 1.4.1 una línea dentro de un recuadro nunca se compone en bandera, por mucho que se estire: la ficha sin partición conserva una línea con espacios de casi cuatro veces el normal, que en el texto corrido se compondría en bandera.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: El ensayo «El problema de los ríos» y su versión inglesa, «The river problem», el banco de pruebas y el diagrama, escritos y dibujados para esta receta: Postext Cookbook, CC-BY-4.0
- Tipografías: Petrona (OFL-1.1), Bricolage Grotesque (OFL-1.1), Source Code Pro (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 016 · Español justificado en una novela de bolsillo](https://postext.dev/es/cookbook/spanish-pocket-novel.md): El comienzo de Marianela en edición de bolsillo: separación silábica del español, espacios de menos de 1,7×, capitular alzada y el plano como Figura 1. · Nivel 2 (Intermedio) · Narrativa, teatro y prosa literaria
- [N.º 055 · Una galerada con cada fallo marcado en rojo](https://postext.dev/es/cookbook/proof-sheet-diagnostics.md): Una galerada revisada dos veces, con los fallos que señala el motor y los que encuentra el propio pen marcados en rojo en la página y ligados a su Markdown. · Nivel 3 (Avanzado) · Periódicos y boletines
- [N.º 013 · Una página de libro sobre una rejilla base](https://postext.dev/es/cookbook/baseline-grid-book-page.md): Una página a dos columnas con márgenes en espejo y una caja de 44 interlineados justos, de modo que el texto cae en la misma rejilla a ambos lados del lomo. · Nivel 2 (Intermedio) · Cualquier género
