# Espaçamento uniforme em colunas estreitas justificadas

> Quebra Knuth–Plass com os espaços entre 0,8 e 1,6 vez o normal, ao lado de um controle guloso com linhas frouxas, isoladas e curtas marcadas a partir do VDT.

- Versão HTML: https://postext.dev/pt/cookbook/justification-lab
- Receita Nº 014 · Tipo e texto · Nível 3 (Avançado) · Saídas: Canvas
- Gêneros: Revistas e fanzines
- Requer postext ≥ 1.19.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/justification-lab/en/p01.webp?v=b01e321e), [2](https://postext.dev/cookbook/justification-lab/en/p02.webp?v=b01e321e), [3](https://postext.dev/cookbook/justification-lab/en/p03.webp?v=b01e321e)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=justification-lab&lang=en (.postext: https://postext.dev/cookbook/justification-lab/en/justification-lab.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [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), [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)

## Em poucas palavras

Uma pequena revista sobre tipografia, em colunas estreitas com as duas bordas do texto alinhadas. Mostra como manter iguais os espaços entre as palavras, ao lado de uma versão com as linhas desiguais marcadas em amarelo.

## O que você vai compor

O número 12 de *Galley*, a revista de um laboratório de tipografia, composta em A5 em duas colunas de uns quarenta caracteres, em que uma linha justificada só tem cinco ou seis espaços entre palavras para absorver a folga. A abertura desenha o modelo de Knuth–Plass sobre uma faixa grafite: uma linha de caixas de palavras cujas molas amarelas de cola se esticam até preencher a medida. O ensaio explica por que as colunas estreitas ficam frouxas e imprime em fonte monoespaçada, entre os parágrafos, os ajustes por trás de cada regra. No pé da página 3, uma bancada de testes compõe um mesmo parágrafo alinhado à esquerda, justificado sem hífens e justificado com eles. O pen também compõe o número com a quebra de primeiro ajuste e sem as proteções contra viúvas e órfãs, e mostra as duas páginas 2 lado a lado acima das páginas, como no cartão desta receita, com as linhas frouxas pintadas de amarelo marca-texto e as linhas isoladas e curtas sinalizadas na margem.

**Esta receita responde a:**

- Como faço para ter uma justificação uniforme em colunas estreitas, com a hifenização certa para cada idioma?
- Como evito viúvas, órfãs e últimas linhas de uma só palavra, e mantenho cada título junto do seu texto?
- Por que uma linha sai alinhada à esquerda ou esticada demais (URLs, palavras compostas longas, palavras longas em células)?
- Como descubro o que está errado no meu documento (avisos, transbordamento, layout que não converge)?

## A resposta curta

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

## Ingredientes

**Ensina**

- [Quebra de linha ótima (Knuth–Plass)](https://postext.dev/pt/docs/justification.md#knuth-plass-o-parágrafo-inteiro-de-uma-vez): Quebra cada parágrafo como um todo para igualar o espaçamento entre palavras, dentro dos limites definidos, em vez de preencher linha por linha.
- [Viúvas, órfãs e linhas curtas](https://postext.dev/pt/docs/configuration.md#órfãs-viúvas-linhas-curtas-e-regras-de-manter-junto): Evita linhas soltas no topo e no pé das colunas e últimas linhas de uma só palavra; mantém cada título com seu texto, e o parágrafo que termina em dois-pontos com a lista que ele introduz.
- [Avisos e diagnóstico](https://postext.dev/pt/docs/configuration.md#depuração): Avisos de layout, problemas de análise e convergência, os recursos de apoio Destacar linhas frouxas e Negativo da página, e o painel Revisão do Sandbox.

**Também usa**

- [Hifenização e idioma do documento](https://postext.dev/pt/docs/justification.md#idiomas-compatíveis)
- [Recuos, alinhamento e espaço entre parágrafos](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Colunas dentro de um boxe](https://postext.dev/pt/docs/document-format.md#columns)
- [Boxes aninhados](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`parseMarkdownWithIssues`](https://postext.dev/pt/docs/configuration.md#análise-do-markdown), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

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

## Preparo

### 1 · Aperte os limites e mantenha as proteções

O código deste passo é [a resposta curta](https://postext.dev/pt/cookbook/justification-lab.md#a-resposta-curta) logo acima. O [Knuth–Plass](https://postext.dev/pt/docs/justification.md#knuth-plass-o-parágrafo-inteiro-de-uma-vez), a hifenização, as penalidades de viúvas, órfãs e linhas curtas e os títulos presos ao texto seguinte vêm ligados por padrão, então a edição publicada só estreita os [limites do espaçamento entre palavras](https://postext.dev/pt/docs/justification.md#limites-do-espaçamento-entre-palavras) para 0,8 e 1,6 vez um espaço normal (os padrões são 0,6 e 2). Uma linha acima do limite superior custa mais que qualquer hífen ou linha curta, então o algoritmo tenta outras quebras antes; tire os dois limites e este ensaio compõe duas linhas acima de 1,6. A hifenização segue `locale` pelo código exato: `'es'`, `'fr'` e `'de'` têm os seus próprios padrões do TeX, enquanto `'es-ES'` é hifenizado como inglês americano, sem nenhum aviso.

### 2 · Marque as linhas frouxas a partir da árvore de layout

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

Só o Sandbox respeita `debug.looseLineHighlight`, e `doc.warnings` nunca lista uma linha frouxa, então `marks()` lê o próprio [VDT](https://postext.dev/pt/docs/justification.md#depuração-de-linhas-frouxas). Cada linha justificada traz `justifiedSpaceRatio`, um parágrafo dividido entre duas colunas volta como um bloco por fragmento, e uma linha acima de 3× não tem proporção, porque o motor [a compõe alinhada à esquerda](https://postext.dev/pt/docs/justification.md#linhas-que-o-algoritmo-não-consegue-preencher) e a marca com `line.ragged`. Com quarenta caracteres, os limites sozinhos não mantêm todas as linhas abaixo de 1,6; enquanto o texto do ensaio era ajustado, as marcas mostravam quais frases reescrever.

### 3 · Ponha o controle ao lado da página publicada

```js
// script.js, linhas 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
}
```

As duas edições usam o mesmo design, então qualquer diferença entre as duas páginas 2 vem do algoritmo de quebra e das suas proteções. Em inglês, o controle guloso deixa 47 das suas 110 linhas justificadas acima de 1,6 e 30 acima de 2, três delas acima de 3× e alinhadas à esquerda pelo motor, enquanto a edição Knuth–Plass mantém todas as 105 entre 0,80 e 1,60. Na página 2, o controle também leva a última linha de um parágrafo, sozinha, para o alto de uma coluna, o que `avoidOrphans: false` permite, e termina outro parágrafo numa linha curta.

### 4 · Dê a cada ajuste um boxe próprio

```js
// script.js, linhas 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) } },
];
```

Um boxe compõe todas as colunas de um grupo [`:::columns`](https://postext.dev/pt/docs/document-format.md#columns) com um só estilo de texto, então cada ficha é um boxe aninhado com o seu próprio `body`, e `breaks="3"` empilha na primeira coluna a ficha alinhada à esquerda e a ficha sem hifenização. A ficha alinhada à esquerda dispensa `hyphenation: false`, porque texto alinhado à esquerda nunca é hifenizado. A bancada flutua para o pé da página 3 com `placement: 'bottom'`, e o seu espaçamento interno inferior repete a moldura de 4 mm porque o `marginBottom` da última ficha é descartado no fim do boxe.

### 5 · Desenhe o modelo na abertura

```js
// script.js, linhas 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) })),
  ] },
});
```

O diagrama é um [elemento de imagem](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem) do design do H1. Como figura, seria um flutuante `top` na largura da página, e um flutuante `top` citado na página 1 abre a página 2. A faixa é uma caixa, e as caixas contam para a altura que uma abertura reserva, enquanto as imagens não contam, então o texto começa sob a faixa sem `minHeight`, uma linha mais abaixo por causa do `marginBottom` padrão do H1. Os rótulos são texto de design posto sobre as coordenadas do próprio desenho, já que um SVG desenhado como imagem não pode usar as fontes web da página.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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.19.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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** 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.
 *  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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  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 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 = `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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** 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, '-'); }

/** 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 v1 ── 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. */
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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Hifenize um texto em alemão

Os padrões são buscados pelo código exato, então um texto em alemão precisa de `'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
```

### Marque só as piores linhas

Suba o limiar de 1,6 para 2 e o marca-texto pinta só as linhas cujos espaços passaram do dobro.

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

## Erros comuns

- **avoidWidows cuida do pé da coluna, avoidOrphans do alto.** O Postext dá nomes próprios às duas linhas isoladas: avoidWidows (widowMinLines, widowPenalty) impede que a primeira linha de um parágrafo fique sozinha no pé de uma coluna, e avoidOrphans (orphanMinLines, orphanPenalty) impede que a última fique sozinha no alto da seguinte. Muitos manuais de estilo usam os dois nomes ao contrário, então escolha a configuração pelo lugar onde ela age. As duas vêm ativadas e funcionam como penalidades: a diagramação compara cada uma com as linhas vazias que obedecê-la deixaria.
- **Texto alinhado à esquerda nunca é hifenizado.** A hifenização só se aplica ao texto justificado; o texto alinhado à esquerda quebra entre palavras, então uma coluna estreita em bandeira fica muito irregular. Justifique o trecho ou aumente a medida.
- **Só 8 idiomas têm hifenização, com o código exato.** A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano.
- **A maioria dos avisos só existe no Sandbox.** Ids, estilos e diretivas desconhecidos, fontes ausentes e linhas frouxas são verificados pelo Sandbox, não pelo motor: um pen recebe apenas doc.warnings e parseMarkdownWithIssues. Um estilo desconhecido é substituído sem aviso e uma diretiva desconhecida é impressa como texto, então confira os seus ids.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().
- **:::columns só funciona dentro de um boxe e nunca se divide.** :::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um.
- **As imagens de uma abertura nunca contam para a altura que ela reserva.** No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar.
- **Um flutuante 'top' nunca cai na página que o cita.** Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **O lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.
- **Aviso de diagramação: Linha frouxa** (`looseLine`). Uma linha justificada estica os espaços além do limite, quase sempre por causa de uma palavra longa, uma URL ou uma medida estreita. Solução: Ative a hifenização no idioma certo, aumente a medida, reescreva o trecho ou componha essa passagem alinhada à esquerda. ([Documentação](https://postext.dev/pt/docs/justification.md#depuração-de-linhas-frouxas))

- Um boxe que fica no fim de uma coluna desce para o pé dela quando as colunas são balanceadas, longe do parágrafo a que pertence. Ajuste o texto para que a coluna fique cheia, como foi preciso com o boxe de ajustes sob “Fences for the glue”.
- A ficha sem hifenização não mostra os espaços mais largos de que precisaria. Uma linha justificada cujos espaços passariam de três vezes um espaço normal é composta alinhada à esquerda, com o espaçamento natural, num boxe tal como no texto corrido, e por isso as duas primeiras linhas da ficha terminam antes da medida. As linhas abaixo desse teto continuam justificadas, e são delas os espaços largos que a ficha mostra.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: 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
- Tipos: Petrona (OFL-1.1), Bricolage Grotesque (OFL-1.1), Source Code Pro (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 016 · Espanhol justificado num romance de bolso](https://postext.dev/pt/cookbook/spanish-pocket-novel.md): O início de Marianela em edição de bolso: hifenização espanhola, espaços abaixo de 1,7×, sem linhas curtas, capitular elevada e o mapa como Figura 1. · Nível 2 (Intermediário) · Ficção, teatro e prosa literária
- [Nº 055 · Uma prova com cada erro marcado em vermelho](https://postext.dev/pt/cookbook/proof-sheet-diagnostics.md): Uma prova de jornal revisada duas vezes, com cada erro que o motor aponta ou que o próprio pen encontra marcado em vermelho na página e ligado ao seu Markdown. · Nível 3 (Avançado) · Jornais e boletins
- [Nº 013 · Uma página de livro sobre a grade de linhas de base](https://postext.dev/pt/cookbook/baseline-grid-book-page.md): Página em duas colunas espelhadas com mancha de exatamente 44 entrelinhas: o texto fica na mesma grade dos dois lados da medianiz e da lombada. · Nível 2 (Intermediário) · Qualquer gênero
