# Des espaces réguliers en colonnes étroites justifiées

> Coupure Knuth–Plass, espaces tenues entre 0,8 et 1,6 fois la normale, face à un témoin glouton aux lignes lâches, isolées et trop courtes repérées par le VDT.

- Version HTML: https://postext.dev/fr/cookbook/justification-lab
- Recette Nº 014 · Caractères et texte · Niveau 3 (Avancé) · Sorties: Canvas
- Genres: Magazines et fanzines
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/justification-lab/en/p01.webp?v=db17de4d), [2](https://postext.dev/cookbook/justification-lab/en/p02.webp?v=db17de4d), [3](https://postext.dev/cookbook/justification-lab/en/p03.webp?v=db17de4d)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=justification-lab&lang=en (.postext: https://postext.dev/cookbook/justification-lab/en/justification-lab.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/justification-lab.md), [es](https://postext.dev/es/cookbook/justification-lab.md), [ca](https://postext.dev/ca/cookbook/justification-lab.md), [pt](https://postext.dev/pt/cookbook/justification-lab.md), [zh](https://postext.dev/zh/cookbook/justification-lab.md), [ja](https://postext.dev/ja/cookbook/justification-lab.md), [ar](https://postext.dev/ar/cookbook/justification-lab.md)

## En bref

Une petite revue de typographie, composée en colonnes étroites alignées des deux côtés. Elle montre comment garder des blancs réguliers entre les mots, à côté d’une version dont les lignes irrégulières sont surlignées en jaune.

## Ce que vous allez composer

Le numéro 12 de *Galley*, la revue d’un atelier typographique, composé en A5 sur deux colonnes d’environ quarante caractères, où une ligne justifiée n’a que cinq ou six espaces pour absorber le jeu. L’ouverture dessine le modèle de Knuth–Plass sur une bande graphite : une ligne de boîtes de mots dont les ressorts jaunes de « glue » s’étirent pour remplir la justification. L’essai explique pourquoi les colonnes étroites deviennent lâches et imprime, entre ses paragraphes, en chasse fixe, les réglages qui se cachent derrière chaque règle. Au pied de la page 3, un banc d’essai compose un même paragraphe en drapeau, justifié sans césure et justifié avec. Le pen compose aussi le numéro avec une coupure au premier ajustement et sans les protections contre les veuves et les orphelines, et montre les deux pages 2 côte à côte au-dessus des pages, comme sur la vignette de cette recette, les lignes lâches lavées de jaune surligneur, les lignes isolées et les fins de paragraphe trop courtes signalées dans la marge.

**Cette recette répond à:**

- Comment obtenir une justification régulière en colonnes étroites, avec la bonne césure pour chaque langue ?
- Comment éviter veuves, orphelines et dernières lignes d’un seul mot, et garder un titre avec son texte ?
- Pourquoi une ligne est-elle composée en drapeau ou trop étirée (URL, longs mots composés, mots longs dans les cellules) ?
- Comment savoir ce qui ne va pas dans mon document (avertissements, débordements, mise en page qui ne converge pas) ?

## La réponse courte

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

## Ingrédients

**Enseigne**

- [Coupure optimale des lignes (Knuth–Plass)](https://postext.dev/fr/docs/justification.md#knuth-plass-voir-le-paragraphe-entier): Coupe chaque paragraphe dans son ensemble pour obtenir un espacement des mots régulier, dans les limites de l’espacement des mots, au lieu de remplir ligne par ligne.
- [Veuves, orphelines et fins de paragraphe trop courtes](https://postext.dev/fr/docs/configuration-text.md#orphelines-veuves-lignes-creuses-et-règles-de-maintien): Écarte les lignes isolées des hauts et des pieds de colonne et les dernières lignes d’un seul mot des paragraphes ; garde un titre avec son texte, et un paragraphe qui finit par un deux-points avec la liste qu’il introduit.
- [Avertissements et diagnostics](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#débogage): Les avertissements de mise en page, les problèmes d’analyse et la convergence, les aides Highlight Loose Lines et Page Negative, et le panneau Vérifications du Sandbox.

**Utilise aussi**

- [Césure et langue du document](https://postext.dev/fr/docs/justification.md#langues-prises-en-charge)
- [Retraits, alignement et espacement des paragraphes](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Colonnes dans un encadré ou dans le texte](https://postext.dev/fr/docs/document-format.md#columns)
- [Encadrés imbriqués](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), `findLooseLines`, [`parseMarkdownWithIssues`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#analyse), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

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

## Préparation

### 1 · Resserrer les bornes, garder les protections

Le code de cette étape est dans [la réponse courte](https://postext.dev/fr/cookbook/justification-lab.md#la-réponse-courte) plus haut. [Knuth–Plass](https://postext.dev/fr/docs/justification.md#knuth-plass-voir-le-paragraphe-entier), la césure, les pénalités de veuve, d’orpheline et de fin de paragraphe trop courte et les titres solidaires du paragraphe suivant sont actifs par défaut : l’édition publiée se contente donc de resserrer les [bornes d’espacement des mots](https://postext.dev/fr/docs/justification.md#limites-de-lespacement-des-mots) à 0,8 et 1,6 fois une espace normale (les valeurs par défaut sont 0,6 et 2). Une ligne au-delà de la borne haute coûte plus cher que n’importe quelle césure ou fin de paragraphe courte, si bien que l’algorithme essaie d’abord d’autres coupures ; supprimez les deux bornes, et cet essai compose deux lignes au-delà de 1,6. La césure suit `locale` : `'es'`, `'fr'` et `'de'` ont leurs propres motifs TeX, une balise régionale comme `'es-ES'` prend ceux de sa langue, et une langue sans motifs est coupée comme l’anglais américain, avec un avertissement dans la console.

### 2 · Marquer les lignes lâches depuis l’arbre de mise en page

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

`findLooseLines` renvoie les lignes justifiées dont les espaces s’étirent au-delà d’un seuil, ici la borne haute, chacune avec la bande que couvre un surlignage ([Lignes lâches dans votre propre canvas](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#lignes-lâches-dans-votre-propre-canvas)). `marks()` ajoute ce que cette liste laisse de côté, lu dans le [VDT](https://postext.dev/fr/docs/justification.md#débogage-des-lignes-lâches) : une ligne au-delà de 3× n’a pas de rapport, parce que le moteur [la compose en drapeau](https://postext.dev/fr/docs/justification.md#lignes-que-le-moteur-de-coupure-ne-peut-pas-remplir) et la signale plutôt par `line.ragged`, et un paragraphe coupé entre deux colonnes revient sous forme d’un bloc par fragment, ce qui permet de trouver les lignes isolées. À quarante caractères, les bornes seules ne gardent pas toutes les lignes sous 1,6 ; pendant le calibrage de l’essai, les marques montraient quelles phrases reformuler.

### 3 · Placer le témoin à côté de la page publiée

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

Les deux éditions partagent une même maquette : toute différence entre les deux pages 2 vient donc de l’algorithme de coupure et de ses protections. En anglais, le témoin glouton laisse 47 de ses 110 lignes justifiées au-delà de 1,6 et 30 au-delà de 2, dont trois au-delà de 3×, que le moteur compose en drapeau, tandis que l’édition Knuth–Plass garde les 105 entre 0,80 et 1,60. Sur la page 2, le témoin renvoie aussi seule en tête de colonne la dernière ligne d’un paragraphe, ce que permet `avoidOrphans: false`, et termine un autre paragraphe sur une ligne trop courte.

### 4 · Donner à chaque réglage sa propre boîte

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

Une boîte compose toutes les colonnes d’un groupe [`:::columns`](https://postext.dev/fr/docs/document-format.md#columns) dans un seul style de corps : chaque épreuve est donc une boîte imbriquée avec son propre `body`, et `breaks="3"` empile l’épreuve en drapeau et l’épreuve sans césure dans la première colonne. L’épreuve en drapeau n’a pas besoin de `hyphenation: false` : le texte en drapeau garde ses mots entiers, sauf si `bodyText.hyphenation.ragged` est activé. Le banc d’essai flotte au pied de la page 3 avec `placement: 'bottom'`, et son remplissage du bas reprend le cadre de 4 mm, car le `marginBottom` de la dernière épreuve est abandonné à la fin de la boîte.

### 5 · Dessiner le modèle dans l’ouverture

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

Le schéma est un [élément image](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image) de la maquette du H1. En figure, ce serait un flottant `top` sur toute la largeur de la page, et un flottant `top` cité en page 1 ouvre la page 2. La bande descend sous le schéma, et une ouverture réserve la hauteur jusqu’à son élément le plus bas : le texte commence donc sous la bande sans `minHeight`, une ligne plus bas à cause du `marginBottom` par défaut du H1. Les étiquettes sont du texte de maquette posé sur les coordonnées propres du dessin, composé et interlettré comme les autres étiquettes de l’ouverture.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

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

### script.js

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

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

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

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

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

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

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

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

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Galley"
subtitle: "Notes from the type bench · No. 12"
author: "Galley"
---

# The river problem {kicker="Galley · No. 12 · Justification" standfirst="Justify a narrow column and its word spaces are the first thing to open up. How a line breaker that weighs the whole paragraph keeps the grey even, and the eight settings that govern it."}

Hold a newspaper page at arm’s length and half close your eyes. In a good column the text turns into an even grey. In a bad one, pale channels wander down through it, from a gap in one line to the gap below it. Printers call them rivers. They form when the spaces open up on several lines at once and happen to fall in line, and nothing opens spaces faster than a narrow measure.

A column of forty characters has five or six word spaces to a line. Carry one long word on to the next line and those few spaces must share its whole width between them: each may have to grow to twice its width, or more. In a column twice as wide, twice as many spaces take up the same width, and each grows only half as much as before.

## Boxes, glue and penalties

In 1981, Donald E. Knuth and Michael F. Plass described a paragraph the way TeX still sees it. Words are boxes, fixed in width. The spaces between them are glue, with a natural width and a limit to how far it may stretch or shrink. Penalties mark the places where a line may end, each with a price: ending on a hyphen costs something, ending between two words costs nothing. The drawing at the head of this article shows one line in those terms, as measured and as set, with its glue stretched until the line fills the measure and the word that ran past it broken at a hyphen.

The breaker then prices every line by how far its glue had to move, cubing the figure so one very loose line costs more than a string of slightly loose ones, and then adds the penalties. Of all the ways to break the paragraph, it keeps the one whose total is lowest, looking back as far as the first line to find it.

## Greedy and total fit

The older method, first fit, survives on the web. It fills each line with as many words as will fit before moving on, and a line once set stays set, so one more short word taken now can leave the next line with a gap that no later break can close. The whole-paragraph method sets one line a little looser when that spares the next one a gap. Across a whole column the trade leaves fewer loose lines than first fit, and so fewer gaps that can line up into rivers.

## Fences for the glue

Two numbers fence the glue in: a space may shrink to 80 per cent of its natural width and grow to 160 per cent. Much tighter, and the breaker runs out of ways to fill a line; any looser, and the eye sees the gaps. Past the upper fence, every extra stretch costs more than any hyphen or short last line, so the breaker looks for another way to fill the line first.

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

maxWordSpacing: 1.6
:::

:::callout{type="bench" title="Bench test · one paragraph, three settings"}
:::columns{count=2 breaks="3"}
:::callout{type="ragged" title="ragged · textAlign: 'left'"}
Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice.
:::

:::callout{type="unhyphenated" title="justified · hyphenation: false"}
Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice.
:::

:::callout{type="justified" title="justified · hyphenation: true"}
Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice.
:::

The same words set three ways, in a measure a little narrower than these columns. Ragged text breaks only between words unless it is told to hyphenate. Justified without hyphens, a few spaces take up all the slack. With hyphens, the spaces stay even at the cost of a few broken words.
:::
:::

## Hyphens

Hyphens hand the breaker more places to end a line, and in a narrow measure they are not optional. Postext hyphenates with the TeX patterns of the document’s language, which it takes from an exact code: *en-us* for this issue and *es* for its Spanish edition. A code it lacks, such as *eu-ES*, falls back to American English with a warning. The patterns serve justified text only; ragged lines break only between words, so a narrow ragged column gets a deep rag.

## Widows, orphans and runts

A paragraph that breaks across columns leaves a line behind or carries one over. A lone first line at the foot of a column and a lone last line at the head of the next are the pair the style manuals forbid, though printers have never agreed which of the two is the widow. Postext settles it by position: avoidWidows applies at the foot of the column, avoidOrphans at its head.

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

orphanPenalty: 1000

slackWeight: 10
:::

Each rule is a price in the breaker’s sums, weighed against the white space that obeying it would leave: a lone line costs its penalty, and the empty lines a split would leave at the foot of a column cost ten times the square of their number. The breaker takes the cheaper way.

A runt is a last line too short to stand alone: one word, or the tail of one, under a full paragraph. Postext prices it inside the line breaker, so a set of breaks that brings a second word down wins whenever the fences allow. When no other set of breaks stays inside the fences, it sets the paragraph one line shorter, tightening the spaces first and then, if it has to, the letters, by no more than ten thousandths of an em.

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

runtPenalty: 1000

maxRuntTracking: 10
:::

## What the eye forgives

A long word can still leave one line of a narrow column a shade looser than its neighbours, and a paragraph that must end somewhere will now and then end on a short line. The breaker can move the extra space from one line to another but cannot remove it. Near a long word it adds a trace to each of five lines rather than leave the whole amount in a single gap, where it would show as a hole in the grey of the column.

A line that still gapes is left to the editor, who can usually close it by changing one word in the sentence.

:::paragraphs{style="colophon"}
Galley is set in Petrona, Bricolage Grotesque and Source Code Pro (SIL Open Font License). Text and diagram: original, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook


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

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

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

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

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

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

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

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. 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 Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) 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],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
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, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
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?.weights.includes(weight) && meta.styles.includes(style))) 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 family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** 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) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer 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

### Couper un texte allemand

La langue choisit les motifs : un texte allemand prend `'de'`, ou une balise régionale comme `'de-AT'`.

```diff
-const locale = t({ en: 'en-us', es: 'es' }); // config().locale: each edition's patterns
+const locale = 'de'; // German patterns; 'de-AT' picks them too
```

### Ne marquer que les pires lignes

Portez le seuil de 1,6 à 2, et le surligneur ne marque plus que les lignes dont les espaces ont plus que doublé.

```diff
-  const loose = findLooseLines(doc, { threshold: FENCES.maxWordSpacing, pageIndex: page.index });
+  const loose = findLooseLines(doc, { threshold: 2, pageIndex: page.index });
```

## Erreurs fréquentes

- **avoidWidows protège le pied d’une colonne, avoidOrphans sa tête.** Postext nomme à sa façon les deux lignes isolées : avoidWidows (widowMinLines, widowPenalty) empêche la première ligne d’un paragraphe de rester seule au pied d’une colonne, et avoidOrphans (orphanMinLines, orphanPenalty) empêche sa dernière ligne de rester seule en tête de la suivante. Beaucoup de manuels de style donnent les deux noms dans l’autre sens : choisissez donc le réglage d’après l’endroit où il agit. Les deux sont activés par défaut, sauf sur une grille de caractères (cjk.grid) : là, avoidWidows est désactivé, et avoidOrphans aussi dans la région Japon, afin qu’une page contienne les lignes que compte sa grille (depuis postext 1.28). Ils fonctionnent comme des pénalités : la mise en page met chacun en balance avec les lignes vides que le respecter laisserait.
- **Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite.** Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite.
- **Avertissement de mise en page: Ligne lâche** (`looseLine`). Une ligne justifiée étire ses espaces au-delà du seuil, en général à cause d’un mot long, d’une URL ou d’une justification étroite. Solution: Activez la césure dans la bonne langue, élargissez la justification, reformulez, ou composez le passage en drapeau. ([Documentation](https://postext.dev/fr/docs/justification.md#débogage-des-lignes-lâches))

- Une boîte laissée en fin de colonne descend au pied de la colonne quand les colonnes sont équilibrées, loin du paragraphe auquel elle appartient. Calibrez le texte pour que la colonne soit pleine, comme il a fallu le faire pour la boîte de réglages sous « Fences for the glue », ou réglez `headings.balancing.closingBox` sur `'off'`, qui ne déplace jamais la boîte.
- L’épreuve sans césure ne montre pas les blancs les plus larges dont elle aurait besoin. Une ligne justifiée dont les espaces devraient s’étirer au-delà de trois fois une espace normale est composée en drapeau avec son espacement naturel, dans une boîte comme dans le texte courant : les deux premières lignes de l’épreuve s’arrêtent donc avant la fin de la justification. Les lignes sous ce plafond restent justifiées, et ce sont leurs blancs, larges, que montre l’épreuve.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: The essay “The river problem” and its Spanish version “El problema de los ríos”, the bench test and the diagram, written and drawn for this recipe: Postext Cookbook, CC-BY-4.0
- Caractères: Petrona (OFL-1.1), Bricolage Grotesque (OFL-1.1), Source Code Pro (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 016 · De l’espagnol justifié dans un roman de poche](https://postext.dev/fr/cookbook/spanish-pocket-novel.md): Le début du chapitre I de Marianela en poche : césure espagnole, espaces sous 1,7×, aucune fin trop courte, lettrine montante et Figura 1 sur le plan. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [Nº 055 · Une épreuve où chaque faute est marquée en rouge](https://postext.dev/fr/cookbook/proof-sheet-diagnostics.md): Une épreuve de journal relue deux fois : chaque faute signalée par le moteur ou trouvée par le pen est marquée en rouge sur la page et liée à son Markdown. · Niveau 3 (Avancé) · Journaux et lettres d’information
- [Nº 013 · Une page de livre sur une grille de ligne de base](https://postext.dev/fr/cookbook/baseline-grid-book-page.md): Une page en miroir sur deux colonnes dont le bloc de texte mesure 44 lignes pile : le texte courant tient sur une seule grille, d’une colonne à l’autre. · Niveau 2 (Intermédiaire) · Tous genres
