# Even word spacing in narrow justified columns

> Knuth–Plass breaking with word spaces held to 0.8–1.6 times normal, set beside a greedy control whose loose lines, lone lines and runts are marked from the VDT.

- HTML version: https://postext.dev/en/cookbook/justification-lab
- Recipe Nº 014 · Type & text · Level 3 (Advanced) · Outputs: Canvas
- Genres: Magazines & zines
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/justification-lab/en/p01.webp?v=4e2eaa90), [2](https://postext.dev/cookbook/justification-lab/en/p02.webp?v=4e2eaa90), [3](https://postext.dev/cookbook/justification-lab/en/p03.webp?v=4e2eaa90)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/justification-lab.md)

## What you'll build

Issue 12 of *Galley*, a type lab's journal, set on A5 in two columns of about forty characters, where a justified line has only five or six word spaces to take up the slack. The opener draws the Knuth–Plass model on a graphite band: a line of word boxes whose yellow springs of glue stretch to fill the measure. The essay explains why narrow columns go loose and prints the settings behind each rule in mono between its paragraphs. At the foot of page 3 a bench test sets one paragraph ragged, justified without hyphens and justified with them. The pen also sets the issue with first-fit breaking and the widow and orphan guards off, and shows both page 2s side by side above the pages, as on this recipe's card, with loose lines washed in highlighter yellow and lone lines and runts tagged in the margin.

**This recipe answers:**

- How do I get even justification in narrow columns, with the right hyphenation for each language?
- How do I avoid widows, orphans and one-word last lines (runts), and keep a heading with its text?
- Why is one line set ragged or over-stretched (URLs, long compounds, long words in cells)?
- How do I find out what is wrong with my document (warnings, overflow, non-converging layout)?

## The short answer

One design, two line breakers: Knuth–Plass inside fences, greedy without.

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

## Ingredients

**Teaches**

- [Optimal line breaking (Knuth–Plass)](https://postext.dev/en/docs/justification.md#knuth-plass-seeing-the-whole-paragraph): Breaks each paragraph as a whole for even word spacing, within word-spacing bounds, instead of filling line by line.
- [Widows, orphans and runts](https://postext.dev/en/docs/configuration.md#orphans-widows-runts-and-keep-together-rules): Keeps lone lines off column tops and feet and one-word last lines out of paragraphs; keeps a heading with its text, and a paragraph ending in a colon with the list it introduces.
- [Warnings and diagnostics](https://postext.dev/en/docs/configuration.md#debug): Layout warnings, parse issues and convergence, the Highlight Loose Lines and Page Negative aids, and the Sandbox's Checks panel.

**Also uses**

- [Hyphenation and document language](https://postext.dev/en/docs/justification.md#supported-locales)
- [Indents, alignment and paragraph spacing](https://postext.dev/en/docs/configuration.md#body-text)
- [Columns inside a box](https://postext.dev/en/docs/document-format.md#columns)
- [Nested boxes](https://postext.dev/en/docs/configuration.md#the-callout-container)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`parseMarkdownWithIssues`](https://postext.dev/en/docs/configuration.md#parsing), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

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

## Method

### 1 · Tighten the fences, keep the guards

The code for this step is [the short answer](#the-short-answer) above. [Knuth–Plass](/en/docs/justification#knuth-plass-seeing-the-whole-paragraph), hyphenation, the widow, orphan and runt penalties and keep-with-next headings are on by default, so the published edition only narrows the [word-spacing bounds](/en/docs/justification#word-spacing-bounds) to 0.8 and 1.6 times a normal space (the defaults are 0.6 and 2). A line past the upper bound costs more than any hyphen or runt, so the breaker tries other breaks first; delete the two bounds and this essay sets two lines past 1.6. Hyphenation follows `locale` by exact code: `'es'`, `'fr'` and `'de'` get their own TeX patterns, while `'es-ES'` is hyphenated as American English with no warning.

### 2 · Mark loose lines from the layout tree

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

Only the Sandbox honours `debug.looseLineHighlight`, and `doc.warnings` never lists a loose line, so `marks()` reads the [VDT](/en/docs/justification#loose-line-debugging) itself. Every justified line carries `justifiedSpaceRatio`, a paragraph split between two columns comes back as one block per fragment, and a line past 3× has no ratio, because the engine [sets it ragged](/en/docs/justification#lines-the-breaker-cannot-fill) and flags it `line.ragged` instead. At forty characters the bounds alone do not keep every line under 1.6; while the essay was being copy-fitted, the marks showed which sentences to reword.

### 3 · Lay the control beside the published page

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

Both editions share one design, so every difference between the two page 2s comes from the line breaker and its guards. In English the greedy control leaves 47 of its 110 justified lines past 1.6 and 30 past 2, three of them past 3× and set ragged by the engine, while the Knuth–Plass edition keeps all 105 between 0.80 and 1.60. On page 2 the control also carries a paragraph's last line alone to the head of a column, which `avoidOrphans: false` allows, and ends another paragraph on a runt.

### 4 · Give each setting a box of its own

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

A box sets all the columns of a [`:::columns`](/en/docs/document-format#columns) group in one body style, so each slip is a nested box with a `body` of its own, and `breaks="3"` stacks the ragged and the unhyphenated slips in the first column. The ragged slip needs no `hyphenation: false`, because ragged text is never hyphenated. The bench floats to the foot of page 3 with `placement: 'bottom'`, and its bottom padding repeats the 4 mm frame because the last slip's `marginBottom` is dropped at the end of the box.

### 5 · Draw the model into the opener

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

The diagram is an [image element](/en/docs/configuration#image-elements) of the H1's design. As a figure it would be a page-wide `top` float, and a `top` float cited on page 1 opens page 2. The band is a box, and boxes count towards the height an opener reserves while images do not, so the text starts under the band without a `minHeight`, a line further down because of the H1's default `marginBottom`. The labels are design text laid on the drawing's own coordinates, since an SVG drawn as an image cannot use the page's web fonts.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/justification-lab

### script.js

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

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

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

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

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

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

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

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

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "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 half as much.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variations

### Hyphenate a German text

Patterns are looked up by exact code, so a German text needs `'de'`.

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

### Mark only the worst lines

Raise the threshold from 1.6 to 2 and the highlighter marks only the lines whose spaces have more than doubled.

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

## Pitfalls

- **avoidWidows guards the foot of a column, avoidOrphans its head.** Postext names the two lone lines its own way: avoidWidows (widowMinLines, widowPenalty) keeps a paragraph's first line from standing alone at the foot of a column, and avoidOrphans (orphanMinLines, orphanPenalty) keeps its last line from standing alone at the head of the next. Many style manuals give the two names the other way round, so pick the setting by where it acts. Both are on by default and work as penalties: the layout weighs each one against the empty lines that obeying it would leave.
- **Ragged text is never hyphenated.** Hyphenation applies to justified text only; ragged-right text breaks between words, so a narrow ragged column gets a deep rag. Justify the passage or widen the measure.
- **Only 8 locales hyphenate, by exact code.** Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English.
- **Most warnings exist only in the Sandbox.** Unknown ids, styles and directives, missing fonts and loose lines are checked by the Sandbox, not the engine: a pen only gets doc.warnings and parseMarkdownWithIssues. An unknown style silently falls back and an unknown directive prints as text, so check your ids.
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **:::columns works only inside a box and never splits.** :::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one.
- **An opener's images never count towards the height it reserves.** In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin.
- **A 'top' float never lands on its citing page.** A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page.
- **Text inside an SVG <img> cannot use web fonts.** An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption.
- **A design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **Layout warning: Loose line** (`looseLine`). A justified line stretches its spaces beyond the threshold, usually because of a long word, a URL or a narrow measure. Fix: Turn on hyphenation in the right language, widen the measure, rephrase, or set the passage ragged. ([Documentation](https://postext.dev/en/docs/justification.md#loose-line-debugging))

- A box left at the end of a column drops to the column's foot when the columns are balanced, away from the paragraph it belongs to. Fit the copy so the column is full, as the settings box under “Fences for the glue” needed.
- In postext 1.4.1 a line inside a box is never set ragged, however far it stretches: the unhyphenated slip keeps lines past three times a normal space (one at six), which the body text would set ragged.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: 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
- Type: Petrona (OFL-1.1), Bricolage Grotesque (OFL-1.1), Source Code Pro (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 016 · Justified Spanish in a pocket novel](https://postext.dev/en/cookbook/spanish-pocket-novel.md): The opening of Marianela’s chapter I as a pocket edition: Spanish hyphenation, word spaces under 1.7×, no runts, a raised initial and Figura 1 on the map. · Level 2 (Intermediate) · Fiction, drama & literary prose
- [Nº 055 · A galley proof with every fault marked in red](https://postext.dev/en/cookbook/proof-sheet-diagnostics.md): A newspaper galley proofed twice, with every fault the engine reports or the pen finds itself marked in red on the page and linked to its Markdown. · Level 3 (Advanced) · Newspapers & newsletters
- [Nº 013 · A book page on a baseline grid](https://postext.dev/en/cookbook/baseline-grid-book-page.md): A mirrored two-column page whose text block is exactly 44 leads tall, so the body text sits on one grid across the gutter and the spine. · Level 2 (Intermediate) · Any genre
