# La columna que la regla de viudas dejaba corta

> Columnas en bandera que acaban en la rejilla: fillShortColumns alarga o acorta una línea un párrafo de la columna corta en vez de dejar un hueco al pie.

- Versión HTML: https://postext.dev/es/cookbook/filling-short-columns
- Receta N.º 177 · Página y retícula · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Revistas y fanzines
- Requiere postext ≥ 1.65.0 · probada con 1.65.0 el 2026-10-31
- Páginas: [1](https://postext.dev/cookbook/filling-short-columns/es/p01.webp?v=c991aab5), [1](https://postext.dev/cookbook/filling-short-columns/es/p04.webp?v=c991aab5), [2](https://postext.dev/cookbook/filling-short-columns/es/p05.webp?v=c991aab5), [3](https://postext.dev/cookbook/filling-short-columns/es/p06.webp?v=c991aab5)
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=filling-short-columns&lang=es (.postext: https://postext.dev/cookbook/filling-short-columns/es/filling-short-columns.postext)
- Última actualización: 2026-10-31
- Otros idiomas: [en](https://postext.dev/en/cookbook/filling-short-columns.md), [ca](https://postext.dev/ca/cookbook/filling-short-columns.md), [de](https://postext.dev/de/cookbook/filling-short-columns.md), [fr](https://postext.dev/fr/cookbook/filling-short-columns.md), [it](https://postext.dev/it/cookbook/filling-short-columns.md), [pt](https://postext.dev/pt/cookbook/filling-short-columns.md), [zh](https://postext.dev/zh/cookbook/filling-short-columns.md), [ja](https://postext.dev/ja/cookbook/filling-short-columns.md), [ar](https://postext.dev/ar/cookbook/filling-short-columns.md)

## En pocas palabras

Cuando una columna se queda una línea por encima del pie porque un párrafo no se puede partir ahí, este ajuste vuelve a cortar las líneas de un párrafo de esa columna para que llegue abajo.

## Lo que vas a componer

Tres páginas de *Litoral*, una revista de costa: un reportaje largo sobre la lente de Fresnel que abre con la pintura de una lente encendida en su linterna. El texto va en dos columnas en bandera de Public Sans, con una línea de rejilla en blanco entre párrafos, y las reglas de viudas y huérfanas dejan al menos dos líneas de cada párrafo a cada lado de un corte. El pen compone el reportaje dos veces. Sin el ajuste, en [la apertura](https://postext.dev/cookbook/filling-short-columns/es/p01.webp?v=c991aab5) la columna derecha se queda una línea por encima de la izquierda: el párrafo siguiente no puede empezar con una sola línea al pie. Con él, en [la misma página](https://postext.dev/cookbook/filling-short-columns/es/p04.webp?v=c991aab5), el párrafo de esa columna se corta en una línea más y las dos columnas acaban en la última línea de la rejilla. En la página siguiente, el ajuste también recoge el aire de más que el equilibrado ponía sobre un ladillo.

**Esta receta responde a:**

- ¿Por qué una columna en bandera acaba una línea antes del pie, y cómo la lleno sin viudas ni huérfanas?
- ¿Cómo consigo columnas a ras del pie y una última página equilibrada (justificación vertical)?
- ¿Cómo evito viudas, huérfanas y últimas líneas de una sola palabra, y mantengo cada título con su texto?

## La respuesta corta

```js
// script.js, líneas 37–55
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
```

## Ingredientes

**Enseña**

- [Columnas cortas rellenas](https://postext.dev/es/docs/configuration-page-flow.md#equilibrado-de-columnas): Cuando las reglas de viudas y huérfanas dejan una columna una o dos líneas por encima del pie, el equilibrado reparte el hueco entre los títulos, listas y párrafos de la columna para que acabe a ras (headings.balancing.fillShortColumns).

**También usa**

- [Equilibrado de columnas](https://postext.dev/es/docs/configuration-page-flow.md#equilibrado-de-columnas)
- [Viudas, huérfanas y líneas cortas](https://postext.dev/es/docs/configuration-page-flow.md#huérfanas-viudas-líneas-cortas-y-reglas-de-cohesión)
- [Corte de líneas óptimo (Knuth–Plass)](https://postext.dev/es/docs/justification.md#knuth-plass-ver-el-párrafo-completo)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-imagen)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#api-pública), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits)

**Tipografías**

- Public Sans (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Caslon Text (OFL-1.1)

## Elaboración

### 1 · Dale a la columna en bandera una palanca propia

```js
// script.js, líneas 37–55
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
```

El equilibrado de columnas rellena una columna corta con líneas de rejilla sobre un título, tras una lista, bajo un recuadro o una figura y, como último recurso, componiendo un párrafo justificado una línea más holgado. Una columna de texto corrido en bandera no tiene nada de eso entre sus ladillos, así que la línea que retienen las reglas de viudas y huérfanas se queda vacía. `fillShortColumns` deja que un párrafo en bandera de esa columna ocupe una línea más con su propio espaciado; cuando ninguno puede, compone uno con una línea menos para que el párrafo de después del corte suba y ocupe esa línea. El motor solo conserva el cambio si acaban cortas menos columnas, así que activarlo nunca empeora una página. Con `maxLinesBeforeLoose: 0` se prueban los párrafos antes que los títulos, y cada ladillo conserva una sola línea de aire encima.

### 2 · Compón el reportaje dos veces y compara

```js
// script.js, líneas 204–208
const withFill = (fill) => ({ ...config, headings: { ...config.headings,
  balancing: { ...balancing, fillShortColumns: fill } } }); // a copy: config keeps true
const build = (fill) => buildDocumentWithFonts({ markdown, resources }, withFill(fill),
  kitFonts(FONTS));
const docs = { off: await build(false), on: await build(true) };
```

Los dos documentos salen de una misma configuración y un mismo Markdown. La segunda composición pasa la configuración tal cual; la primera, una copia con el ajuste apagado, de modo que el paquete que se abre en el Sandbox lleva `fillShortColumns: true`. Los dos botones sobre la mesa cambian de una composición a otra. Solo se llenan hasta el pie las páginas que siguen: la página 3 cierra el reportaje y sus columnas se cortan a la misma altura, igual en las dos composiciones.

### 3 · Abre con la pintura

```js
// script.js, líneas 59–85
const DEPTH = 150; // mm: how far down the page the painting bleeds
const sans = { fontFamily: 'Public Sans', fontWeight: 700, textTransform: 'uppercase' };
const at = (to, edge, x, y, width) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const air = { padding: { bottom: mm(6) } }; // counts: the text starts 6 mm under the byline
const opener = {
  enabled: true,
  slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'lens', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
      fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
      placement: at('container', 'top-right', 0, DEPTH - TOP + 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
      letterSpacing: pt(1.7), color: col('brass'), align: 'left',
      placement: at('container', 'top-left', 0, DEPTH - TOP + 9) },
    { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(46), lineHeight: 1.02, color: col('ink'), align: 'left',
      placement: at('#kicker', 'below', 0, 2.5, 112) },
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: 'Libre Caslon Text',
      italic: true, fontSize: pt(12), lineHeight: 1.3, color: col('sea'), align: 'left',
      placement: at('#headline', 'right-of', 8, 2.5) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
      letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
      placement: at('#standfirst', 'below', 0, 3.5) },
  ] },
};
```

La pintura es un elemento de imagen anclado al sangrado, de 150 mm de alto, así que llega al corte por tres lados, algo que ningún flotante hace. El antetítulo, el título, la entradilla y la firma salen de los atributos de la línea `#`. La firma termina en un relleno de 6 mm que cuenta en la altura de la apertura, así que las columnas empiezan en la primera línea de rejilla por debajo.

### 4 · Cabeceras fuera de la apertura

```js
// script.js, líneas 89–106
const label = { ...sans, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.3),
  color: col('muted') };
const folio = { fontFamily: 'Public Sans', fontWeight: 700, fontSize: pt(8.5), color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER, 12) },
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 9), 12.35) }, // the smaller size's baseline
  { kind: 'rule', id: `rule-${parity}`, direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { ...pin(`top-${edge}`, s * OUTER, 17),
      size: { width: mm(197) } } },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the opener
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // the opener's only folio
```

La página par nombra la revista y el número desde el frontmatter, la impar el reportaje, y `pages: 'body'` las saca de la apertura, que solo lleva un folio al pie. La etiqueta baja 0,35 mm respecto al folio para que los dos cuerpos compartan línea base. El filete que va debajo mide 197 mm, el ancho de la caja de texto.

## 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/filling-short-columns

### script.js

```js
// ═══ Postext Cookbook · Nº 177 · Filling the column the widow rule left short ═════
// https://postext.dev/en/cookbook/filling-short-columns
// Code: MIT · Text: original (CC BY 4.0) · Painting: diffusion model
// Fonts: Public Sans, Libre Caslon Display, Libre Caslon Text (OFL) · Needs postext ≥ 1.65.0
//
// A magazine long read in two ragged columns, set twice. Without fillShortColumns a column
// whose paragraph would split into a widow ends a line above the foot; with it, a paragraph
// of that column is set a line longer or shorter, and the column ends on the last grid line.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#1b2026', // text: a slate near-black
  brass: '#875a14', // the accent: kicker and crossheads (6:1 on paper)
  sea: '#2c4a5e', // the standfirst
  rule: '#c9ccd0', // hairlines
  muted: '#5d656e', // running heads, credit, 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' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'brass (defaults)', value: { hex: palette.brass, model: 'hex' } },
];
const TOP = 22; // top margin in mm: the opener's container starts here
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.2; // body leading in pt: the baseline grid every column ends on

// #region answer: ragged columns that end on the grid: fillShortColumns
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
// #endregion

// #region opener: the painting bled across the top, then kicker, headline and standfirst
const DEPTH = 150; // mm: how far down the page the painting bleeds
const sans = { fontFamily: 'Public Sans', fontWeight: 700, textTransform: 'uppercase' };
const at = (to, edge, x, y, width) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const air = { padding: { bottom: mm(6) } }; // counts: the text starts 6 mm under the byline
const opener = {
  enabled: true,
  slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'lens', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
      fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
      placement: at('container', 'top-right', 0, DEPTH - TOP + 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
      letterSpacing: pt(1.7), color: col('brass'), align: 'left',
      placement: at('container', 'top-left', 0, DEPTH - TOP + 9) },
    { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(46), lineHeight: 1.02, color: col('ink'), align: 'left',
      placement: at('#kicker', 'below', 0, 2.5, 112) },
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: 'Libre Caslon Text',
      italic: true, fontSize: pt(12), lineHeight: 1.3, color: col('sea'), align: 'left',
      placement: at('#headline', 'right-of', 8, 2.5) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
      letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
      placement: at('#standfirst', 'below', 0, 3.5) },
  ] },
};
// #endregion

// #region heads: magazine and issue on the verso, the story on the recto
const label = { ...sans, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.3),
  color: col('muted') };
const folio = { fontFamily: 'Public Sans', fontWeight: 700, fontSize: pt(8.5), color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER, 12) },
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 9), 12.35) }, // the smaller size's baseline
  { kind: 'rule', id: `rule-${parity}`, direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { ...pin(`top-${edge}`, s * OUTER, 17),
      size: { width: mm(197) } } },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the opener
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // the opener's only folio
// #endregion

const config = {
  locale: t({ en: 'en-gb', es: 'es' }), // each edition's hyphenation patterns
  colorPalette,
  page: { width: mm(225), height: mm(297), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(21), left: mm(16), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // a 94 mm measure, about 62 characters
  bodyText,
  headings: {
    fontFamily: 'Libre Caslon Text', fontWeight: 400, color: col('ink'),
    balancing, // the first build turns it off (see builds)
    levels: [
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), italic: true, color: col('brass'),
        marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above
    ],
  },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Public Sans', fontSize: pt(6.8),
    lineHeight: pt(9.6), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(LEAD) }],
  header, footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const resources = [
  { id: 'lens', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // only the opener draws it, cropped to its 225 × 150 mm box
    bitmap: { fileId: 'lens-1400.jpg', format: 'jpeg', width: 1400, height: 933 },
    altText: t({ en: 'A lit Fresnel lens of brass and glass rings in a lighthouse lantern at '
      + 'dusk, with the sea and a sunset behind the diagonal panes.',
    es: 'Una lente de Fresnel encendida, de anillos de vidrio y latón, en la linterna de un faro '
      + 'al anochecer, con el mar y la puesta de sol tras los cristales en rombo.' }) },
];
const markdown = String.raw`---
title: "Litoral"
subtitle: "Otoño de 2026"
---

# La lente que encendió la costa {kicker="Historia · Faros" standfirst="Hace doscientos años, un ingeniero de caminos con la salud quebrada quitó los espejos de un faro y puso en su lugar anillos de vidrio. Sus lentes aún giran en algunos cabos, cada invierno en menos." byline="Texto de Marta Quiroga" credit="Pintura hecha para este número"}

La torre de Cordouan se alza sobre un arrecife en la desembocadura del Gironda, a seis kilómetros de cada orilla, y con la marea alta parece levantarse sobre el mismo mar. Los canteros de Louis de Foix la empezaron en 1584 y la terminaron en 1611: un faro vestido de palacio, con una capilla real en el tercer piso. Durante dos siglos su luz fue un fuego de roble y brea, y después de aceite de ballena, que ardía en un brasero abierto bajo la linterna. Los marinos que llegaban del golfo de Vizcaya veían a menudo la torre antes que el fuego. En las noches de bruma no veían ni lo uno ni lo otro, y más de un barco encontró el arrecife antes que el canal.

En el verano de 1823 subió por la escalera de Cordouan, en cajas, un aparato nuevo. Era una jaula de latón más alta que un hombre, con paneles de vidrio tallados en escalones, y en su centro una sola lámpara de aceite con varias mechas concéntricas. La primera noche que se encendió, los prácticos de Royan vieron la luz con una nitidez que los viejos reflectores nunca habían dado. El ingeniero que la había diseñado, Augustin Fresnel, tenía treinta y cinco años y ya estaba enfermo de la tuberculosis que lo mataría cuatro años después, en Ville-d'Avray, a las afueras de París, sin ver encendida buena parte de lo que había dibujado.

Fresnel no era hombre de faros. Se formó en la Escuela Politécnica y pasó sus primeros años trazando carreteras en el Drôme para el cuerpo de Puentes y Caminos, y su verdadero trabajo lo hacía de noche, sobre la naturaleza de la luz. Su memoria sobre la difracción ganó el premio de la Academia de Ciencias en 1819, ante un tribunal que esperaba que la luz fuera un chorro de partículas. Ese mismo año, la Comisión de Faros, el organismo que se ocupaba de las luces de la costa francesa, le pidió que revisara sus lámparas. Encontró un derroche de luz que le ofendía como físico.

Primero vino la lámpara. Con el físico François Arago, Fresnel construyó un quemador de varias mechas redondas, una dentro de otra, alimentadas desde abajo por una pequeña bomba para que las llamas nunca se quedaran sin aceite. Daba una llama ancha y estable, mucho más brillante que la de una sola mecha, pero una llama más brillante no servía de nada si casi toda alumbraba el interior de la linterna. La cuestión era cómo mandarla al mar.

## Una lámpara entre prismas

Antes de Fresnel, los mejores faros usaban reflectores. Una lámpara de Argand ardía en el foco de un cuenco de cobre pulido, plateado por dentro, y el cuenco lanzaba la luz hacia delante en un haz. Una torre necesitaba una docena o más de estas lámparas con sus espejos, cada una con su mecha que despabilar. La plata se ennegrecía, los cuencos nunca eran del todo parabólicos y buena parte de la luz que caía sobre ellos se absorbía o se dispersaba. Los torreros pasaban el día sacando brillo, y el haz seguía siendo débil.

En teoría, una lente podía hacerlo mejor, pero una lente del tamaño de un faro habría sido un bloque de vidrio grueso en el centro, tan pesado que agrietaría su propio marco y demasiado turbio para dejar pasar mucha luz. Buffon había propuesto un siglo antes tallar una lente de aumento en escalones y conservar solo la superficie curva, la que desvía la luz. Fresnel llevó la idea más lejos. Construyó su lente con anillos separados, cada uno pulido con su propia curva y todos llevando la luz al mismo punto, de modo que ninguna pieza de vidrio pasaba de unos pocos centímetros de grosor.

Alrededor de la franja central de anillos añadió prismas por arriba y por abajo. Recogen la luz que una lente dejaría escapar hacia el cielo o hacia el mar y la devuelven al horizonte, primero por refracción y, en las filas exteriores, por reflexión dentro del propio vidrio. Fresnel calculó estos prismas en los últimos años de su vida, en cartas a su hermano Léonor sobre sus ángulos. No llegó a ver un faro construido con ellos de arriba abajo: Léonor, que heredó su puesto en la Comisión, vio encenderse los primeros en la década de 1840.

El resultado fue una lámpara dentro de un anillo de prismas que mandaba casi toda su luz adonde un barco podía verla. Una sola mecha en una lente grande de Fresnel hacía el trabajo de la veintena de lámparas y espejos a los que sustituía, con una fracción del aceite. En una noche clara, la luz de un faro de costa se veía a más de veinte millas náuticas, y el límite lo ponía menos la lámpara que la curvatura de la Tierra.

El propio vidrio llegaba en piezas. Cada anillo de una lente grande se tallaba sobre una mesa giratoria contra una herramienta de hierro fundido con la curva justa, y después se pulía con rojo de pulir durante días; las piezas acabadas se asentaban con masilla en su marco de latón. La lente llegaba al faro como un juego de varios cientos de piezas, y el ingeniero que la montaba tenía que alinear cada anillo con la llama con un error de uno o dos milímetros, a ojo, de noche, moviendo una lámpara y mirando dónde caía su luz sobre las rocas.

## Siete órdenes de vidrio

La Comisión pronto necesitó lentes de varios tamaños, y los sucesores de Fresnel las clasificaron en órdenes según la distancia focal, el espacio entre la llama y la cara interior del vidrio. Una lente de primer orden, para los grandes faros de costa, tenía una distancia focal de 920 milímetros y medía unos dos metros y medio de alto. Una de sexto orden, para el muelle de un puerto o la boca de un río, tenía 150 milímetros y la podían llevar dos personas. Entre ambas estaban el segundo, el tercero y el cuarto orden, y más tarde se añadió un tercero y medio para las distancias intermedias de los estuarios.

Cada lente tenía que decir qué faro era. Un marino que ve una luz en una costa oscura necesita saber si es Cordouan, la punta de Grave o un pueblo de pescadores, y la respuesta está en su ritmo. Las luces fijas ardían sin cambios. Las de destellos usaban paneles de anillos como ojos de buey, dispuestos alrededor de la lámpara y movidos por un mecanismo de relojería, de modo que cada panel barría el mar con un haz al pasar. El número de paneles y la velocidad del giro daban a cada torre su propia cuenta de destellos y ocultaciones, impresa en los libros de faros que llevaba todo navegante.

La relojería se daba a mano. Un contrapeso colgaba en un tubo que bajaba por el centro de la torre, y el torrero de guardia lo subía con una manivela cada pocas horas durante toda la noche. Si se dormía, la lente se frenaba y se paraba, y la luz perdía su carácter: para un barco en alta mar se convertía en otro faro, o en ninguno. En la década de 1890, unos ingenieros franceses hicieron flotar la lente giratoria sobre un baño de mercurio, de modo que un empujón pequeño movía varias toneladas de latón y vidrio. La lente podía girar más deprisa, y los destellos se volvieron más cortos y más brillantes.

París fabricó la mayoría de las lentes. Los talleres de Henry-Lepaute, Sautter y Barbier tallaban y pulían los anillos a mano y los enviaban en cajas numeradas a costas de todo el mundo, con planos para montarlos allí mismo. Chance Brothers, cerca de Birmingham, hacía el vidrio de los faros británicos y después lentes propias, entre ellas las mayores de todas, las hiperradiantes de la década de 1880, con una distancia focal de 1330 milímetros. En Estados Unidos, la Junta de Faros creada en 1852 sustituyó los viejos reflectores por lentes francesas en menos de diez años.

## Cuando se fueron los torreros

Una lente podía durar más de un siglo. El vidrio no se gastaba y el latón solo pedía limpieza. Lo que cambió alrededor de las lentes fue la lámpara. El aceite dejó paso, a finales del siglo XIX, a los quemadores de vapor de petróleo con camisa incandescente, y después a las lámparas eléctricas, primero en las torres que tenían suministro cerca y más tarde en casi todas. La luz se hizo más fuerte, pero la lente que la rodeaba siguió siendo la misma, porque ya ponía casi toda esa luz sobre el horizonte.

Para los torreros, la rutina apenas cambió en cien años. Al ponerse el sol encendían la lámpara y daban cuerda a la relojería; durante la noche se turnaban de guardia en la cámara de servicio, bajo la linterna, y anotaban cada pocas horas el tiempo y el estado de la luz en el diario del faro; al amanecer apagaban la luz, corrían unas cortinas de lino alrededor de la lente para que el sol no prendiera fuego a nada a través de ella y empezaban a limpiar. El latón se pulía, y el vidrio se frotaba con alcohol y un paño suave, nunca con nada más duro.

La electricidad también hizo innecesarios a los torreros. Una lámpara que no pedía despabilar la mecha y un motor que no pedía cuerda se podían vigilar a distancia. Durante la segunda mitad del siglo XX, un faro tras otro se automatizó, su familia se mudó a tierra y su puerta se cerró con llave. Los últimos torreros de los faros británicos dejaron North Foreland, en Kent, en noviembre de 1998. En Francia, los guardianes de las torres en mar abierto, las que el servicio llamaba infiernos, estuvieron entre los últimos en irse.

Después se fueron las lentes. Una baliza moderna de diodos pesa unos pocos kilos, gasta menos que un hervidor y no necesita mercurio, que tiene sus propios riesgos para quien sube a la torre. Algunas lentes se desmontaron y se vendieron, otras se rompieron en almacenes y otras se cortaron en trozos para coleccionistas. Las que quedan han ido a parar casi todas a museos, donde las enciende una bombilla por dentro y se ven como lo que son, una máquina para mandar la luz muy lejos.

Algunas siguen trabajando. En Cordouan todavía gira en la linterna una lente de primer orden, y el arrecife que sostiene la torre es Patrimonio de la Humanidad desde 2021. Los visitantes que cruzan con la marea baja pueden subir la escalera, dejar atrás la capilla real, llegar a la galería de la linterna y quedarse un rato junto a los anillos de vidrio, que ya no hay que limpiar cada mañana. La luz sigue mostrando su ritmo a los prácticos del Gironda, blanca sobre el canal y de color sobre los bajos, tal como la leen desde hace doscientos años los barcos que llegan de Vizcaya.

:::paragraphs{style="colophon"}
Compuesto en Public Sans, Libre Caslon Display y Libre Caslon Text (SIL Open Font License) · Texto: Postext Cookbook, CC BY 4.0 · Pintura hecha para este número con un modelo de difusión · La revista y la autora son ficticias.
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  'Public Sans': ['400', '400i', '500', '600', '700'], 'Libre Caslon Display': ['400'],
  'Libre Caslon Text': ['400', '400i'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage('lens-1400.jpg', asset('lens-1400.jpg'));
// #region builds: the same text and design, without and then with fillShortColumns
const withFill = (fill) => ({ ...config, headings: { ...config.headings,
  balancing: { ...balancing, fillShortColumns: fill } } }); // a copy: config keeps true
const build = (fill) => buildDocumentWithFonts({ markdown, resources }, withFill(fill),
  kitFonts(FONTS));
const docs = { off: await build(false), on: await build(true) };
// #endregion
const title = t({ en: 'Filling the column the widow rule left short',
  es: 'La columna que la regla de viudas dejaba corta' });
// Two buttons put either build on the desk.
const LABELS = { off: 'fillShortColumns: false', on: 'fillShortColumns: true' };
const switches = Object.keys(docs).map((key) => Object.assign(document.createElement('button'),
  { type: 'button', value: key, textContent: LABELS[key] }));
const show = (key) => {
  showPages(docs[key], { title });
  for (const b of switches) b.ariaPressed = String(b.value === key);
};
for (const b of switches) b.addEventListener('click', () => show(b.value));
show('on');
document.getElementById('pt-actions').prepend(...switches);
document.head.insertAdjacentHTML('beforeend',
  '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>');

// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta && (await fontsourceWeights(family, style)).includes(weight))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this face (fonts.check() says yes to any family). */
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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

### Deja que los ladillos tomen el hueco primero

Con el orden por defecto, un título toma hasta dos líneas de rejilla antes de que un párrafo se alargue, y los ladillos de la página siguiente vuelven a abrirse.

```diff
-  maxLinesBeforeLoose: 0,
+  maxLinesBeforeLoose: 2,
```

### Justifica el texto

Un párrafo justificado ya podía ocupar una línea más sin el ajuste, dentro de `maxWordSpacing`. Con `fillShortColumns` también puede ocupar una línea menos, y nunca se compone más holgado que su línea más holgada.

```diff
-  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+  textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
```

## Errores frecuentes

- El ajuste cierra un hueco de una línea. Una columna a la que le faltan dos, por ejemplo una que acaba tras el blanco entre párrafos con sitio para una sola línea del siguiente, puede quedarse corta: ajusta el texto con una frase.
- No se usa un párrafo cuya nueva composición acabaría en una línea corta, así que una columna puede quedarse corta cuando solo tiene párrafos breves.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: Una lente de Fresnel encendida en la linterna de un faro al anochecer: Generated With Diffusion Models, original
- Tipografías: Public Sans (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Caslon Text (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 116 · Fotos que crecen hasta llenar la columna](https://postext.dev/es/cookbook/photos-fill-short-columns.md): Cada foto marca su zona segura; el equilibrado puede recortar fuera de ella para alargarla, y la columna que había quedado corta acaba a ras. · Nivel 2 (Intermedio) · Revistas y fanzines
- [N.º 037 · Memoria anual con columnas a ras](https://postext.dev/es/cookbook/annual-report-flush-columns.md): La memoria anual de una cooperativa energética a dos columnas que acaban en la misma línea, con un recuadro de cifras a lo ancho y la última página igualada. · Nivel 3 (Avanzado) · Informes y memorias
- [N.º 004 · Reportaje de revista: de la foto de apertura al signo final](https://postext.dev/es/cookbook/magazine-feature-opener.md): Una apertura advancedDesign pone una foto a sangre y saca del título antetítulo, titular, entradilla y firma; siguen recuadros flotantes y un chip de cierre. · Nivel 3 (Avanzado) · Revistas y fanzines
