# La columna que la regla de vídues deixava curta

> Columnes en bandera que acaben a la reixeta: fillShortColumns allarga o escurça una línia un paràgraf de la columna curta en lloc de deixar un buit al peu.

- Versió HTML: https://postext.dev/ca/cookbook/filling-short-columns
- Recepta Núm. 177 · Pàgina i retícula · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Revistes i fanzins
- Requereix postext ≥ 1.65.0 · provada amb 1.65.0 el 2026-10-31
- Pàgines: [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)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=filling-short-columns&lang=es (.postext: https://postext.dev/cookbook/filling-short-columns/es/filling-short-columns.postext)
- Última actualització: 2026-10-31
- Altres idiomes: [en](https://postext.dev/en/cookbook/filling-short-columns.md), [es](https://postext.dev/es/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 poques paraules

Quan una columna es queda una línia per sobre del peu perquè un paràgraf no es pot partir allà, aquest ajust torna a tallar les línies d'un paràgraf d'aquesta columna perquè arribi a baix.

## Què compondràs

Tres pàgines de *Litoral*, una revista de costa: un reportatge llarg sobre la lent de Fresnel que s'obre amb la pintura d'una lent encesa a la seva llanterna. El text va en dues columnes en bandera de Public Sans, amb una línia de reixeta en blanc entre paràgrafs, i les regles de vídues i òrfenes deixen almenys dues línies de cada paràgraf a cada costat d'un tall. El pen compon el reportatge dues vegades. Sense l'ajust, a [l'obertura](https://postext.dev/cookbook/filling-short-columns/es/p01.webp?v=c991aab5) la columna dreta es queda una línia per sobre de l'esquerra: el paràgraf següent no pot començar amb una sola línia al peu. Amb l'ajust, a [la mateixa pàgina](https://postext.dev/cookbook/filling-short-columns/es/p04.webp?v=c991aab5), el paràgraf d'aquesta columna es talla en una línia més i les dues columnes acaben a l'última línia de la reixeta. A la pàgina següent, l'ajust també recull l'aire de més que l'equilibri posava sobre un subtítol.

**Aquesta recepta respon a:**

- Per què una columna en bandera acaba una línia abans del peu, i com l'omplo sense vídues ni òrfenes?
- Com aconsegueixo columnes arran del peu i una última pàgina equilibrada (justificació vertical)?
- Com evito vídues, òrfenes i últimes línies d'una sola paraula, i mantinc cada títol amb el seu text?

## La resposta curta

```js
// script.js, línies 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,
};
```

## Ingredients

**Ensenya**

- [Columnes curtes omplertes](https://postext.dev/ca/docs/configuration-page-flow.md#equilibratge-de-columnes): Quan les regles de vídues i òrfenes deixen una columna una o dues línies per sobre del peu, l'equilibri reparteix el buit entre els títols, llistes i paràgrafs de la columna perquè acabi arran (headings.balancing.fillShortColumns).

**També fa servir**

- [Equilibri de columnes](https://postext.dev/ca/docs/configuration-page-flow.md#equilibratge-de-columnes)
- [Vídues, òrfenes i línies curtes](https://postext.dev/ca/docs/configuration-page-flow.md#òrfenes-vídues-línies-curtes-i-regles-de-cohesió)
- [Tall de línies òptim (Knuth–Plass)](https://postext.dev/ca/docs/justification.md#knuth-plass-veure-el-paràgraf-sencer)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-dimatge)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits)

**Tipus de lletra**

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

## Elaboració

### 1 · Dona a la columna en bandera una palanca pròpia

```js
// script.js, línies 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,
};
```

L'equilibri de columnes omple una columna curta amb línies de reixeta sobre un títol, després d'una llista, sota un requadre o una figura i, com a últim recurs, component un paràgraf justificat una línia més ample. Una columna de text corregut en bandera no té res d'això entre els seus subtítols, així que la línia que retenen les regles de vídues i òrfenes es queda buida. `fillShortColumns` deixa que un paràgraf en bandera d'aquesta columna ocupi una línia més amb el seu propi espaiat; quan cap no pot, en compon un amb una línia menys perquè el paràgraf de després del tall pugi i ocupi aquesta línia. El motor només conserva el canvi si acaben curtes menys columnes, així que activar-lo mai no empitjora una pàgina. Amb `maxLinesBeforeLoose: 0` es proven els paràgrafs abans que els títols, i cada subtítol conserva una sola línia d'aire a sobre.

### 2 · Compon el reportatge dues vegades i compara

```js
// script.js, línies 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) };
```

Els dos documents surten d'una mateixa configuració i un mateix Markdown. La segona composició passa la configuració tal com és; la primera, una còpia amb l'ajust apagat, de manera que el paquet que s'obre al Sandbox porta `fillShortColumns: true`. Els dos botons sobre la taula canvien d'una composició a l'altra. Només s'omplen fins al peu les pàgines que continuen: la pàgina 3 tanca el reportatge i les seves columnes es tallen a la mateixa altura, igual en les dues composicions.

### 3 · Obre amb la pintura

```js
// script.js, línies 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 és un element d'imatge ancorat al sagnat, de 150 mm d'alt, així que arriba al tall per tres costats, cosa que cap flotant no fa. L'avanttítol, el títol, l'entradeta i la signatura surten dels atributs de la línia `#`. La signatura acaba en un farciment de 6 mm que compta en l'alçada de l'obertura, així que les columnes comencen a la primera línia de reixeta de sota.

### 4 · Capçaleres fora de l'obertura

```js
// script.js, línies 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 parella anomena la revista i el número des del frontmatter, la senar el reportatge, i `pages: 'body'` les treu de l'obertura, que només porta un foli al peu. L'etiqueta baixa 0,35 mm respecte al foli perquè els dos cossos comparteixin línia de base. El filet de sota fa 197 mm, l'amplada de la caixa de text.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Deixa que els subtítols prenguin el buit primer

Amb l'ordre per defecte, un títol pren fins a dues línies de reixeta abans que un paràgraf s'allargui, i els subtítols de la pàgina següent tornen a obrir-se.

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

### Justifica el text

Un paràgraf justificat ja podia ocupar una línia més sense l'ajust, dins de `maxWordSpacing`. Amb `fillShortColumns` també pot ocupar una línia menys, i mai no es compon més ample que la seva línia més ampla.

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

## Errors freqüents

- L'ajust tanca un buit d'una línia. Una columna a la qual en falten dues, per exemple una que acaba després del blanc entre paràgrafs amb lloc per a una sola línia del següent, es pot quedar curta: ajusta el text amb una frase.
- No es fa servir un paràgraf la nova composició del qual acabaria en una línia curta, així que una columna es pot quedar curta quan només té paràgrafs breus.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: A lit Fresnel lens in a lighthouse lantern at dusk: Generated With Diffusion Models, original
- Tipus de lletra: Public Sans (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Caslon Text (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 116 · Fotos que creixen fins a omplir la columna](https://postext.dev/ca/cookbook/photos-fill-short-columns.md): Cada foto marca la seva zona segura; l'equilibratge pot retallar-ne l'exterior per allargar-la, i la columna que havia quedat curta acaba arran. · Nivell 2 (Intermedi) · Revistes i fanzins
- [Núm. 037 · Memòria anual amb columnes arran](https://postext.dev/ca/cookbook/annual-report-flush-columns.md): La memòria anual d'una cooperativa energètica a dues columnes que acaben a la mateixa línia, amb un requadre de xifres a tot l'ample i l'última pàgina igualada. · Nivell 3 (Avançat) · Informes i memòries
- [Núm. 004 · Reportatge de revista: de la foto d'obertura al signe final](https://postext.dev/ca/cookbook/magazine-feature-opener.md): Una obertura advancedDesign posa una foto a sang i treu del títol avantítol, titular, entradeta i signatura; segueixen requadres flotants i un xip de tancament. · Nivell 3 (Avançat) · Revistes i fanzins
