# Um jornal standard em seis colunas

> Quatro páginas de um diário em formato standard: seis colunas com fio, fotos e boxes em duas a cinco delas, anúncios na largura da página e opinião em cinco.

- Versão HTML: https://postext.dev/pt/cookbook/broadsheet-six-columns
- Receita Nº 133 · Página e grade · Nível 3 (Avançado) · Saídas: Canvas
- Gêneros: Jornais e boletins
- Requer postext ≥ 1.18.0 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/broadsheet-six-columns/en/p01.webp?v=49f78219), [2](https://postext.dev/cookbook/broadsheet-six-columns/en/p02.webp?v=49f78219), [3](https://postext.dev/cookbook/broadsheet-six-columns/en/p03.webp?v=49f78219), [4](https://postext.dev/cookbook/broadsheet-six-columns/en/p04.webp?v=49f78219)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=broadsheet-six-columns&lang=en (.postext: https://postext.dev/cookbook/broadsheet-six-columns/en/broadsheet-six-columns.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [en](https://postext.dev/en/cookbook/broadsheet-six-columns.md), [es](https://postext.dev/es/cookbook/broadsheet-six-columns.md), [ca](https://postext.dev/ca/cookbook/broadsheet-six-columns.md), [zh](https://postext.dev/zh/cookbook/broadsheet-six-columns.md), [ja](https://postext.dev/ja/cookbook/broadsheet-six-columns.md), [ar](https://postext.dev/ar/cookbook/broadsheet-six-columns.md)

## Em poucas palavras

Quatro páginas de um jornal diário grande, compostas em seis colunas estreitas. Mostra como pôr uma foto ou um boxe sobre várias colunas e como dar cinco colunas a uma página.

## O que você vai compor

Quatro páginas do *The Corrington Despatch*, um diário inventado, num formato standard de 375 × 597 mm. O texto corre em seis colunas de 55,2 mm com medianizes de 4 mm e um fio fino em cada uma: uma medida de uns 38 caracteres em 9 pt, a de um jornal standard de verdade. A primeira página traz o cabeçalho com duas orelhas, uma faixa de chamadas, uma matéria principal cuja manchete e foto ocupam quatro colunas, uma segunda matéria com foto em duas colunas, uma coluna de notas, uma tabela de financiamento e um boxe de dados em duas colunas, e um anúncio no pé. A página 2 abre com a continuação de uma matéria da capa, a página 3 passa para cinco colunas com os editoriais, um artigo de opinião e as cartas, e a última página traz uma crítica de teatro e a previsão do tempo.

**Esta receita responde a:**

- Como faço para compor um jornal standard em seis colunas, com fotos e boxes que ocupem duas, três ou quatro?
- O Postext consegue compor três ou mais colunas de texto, ou fazer o texto contornar uma imagem?

## A resposta curta

```js
// script.js, linhas 70–86
const layout = { layoutType: 'multiple', columnCount: 6, gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
// A float across k columns takes the head of the first run of k empty columns that start
// level, counted left to right from the column the text is in, or the foot of that column
// and the empty ones after it. So the order of the fences places them:
//   :::callout{type="head" placement="top" columns="4"}  ← columns 1–4, the text not begun
//   ::resource{id="quays"}                              ← columns 1–4 again, under the box
//   …the lead runs in columns 1–4…
//   :::callout{type="head" placement="top" columns="2"}  ← columns 5–6: 1–4 hold text now
const photo = (id, file, [w, h], columns, extra) => ({ id, typeId: 'photo', kind: 'bitmap',
  createdAt: 0, updatedAt: 0, bitmap: { fileId: file, format: 'jpeg', width: w, height: h },
  placement: { position: 'top', span: 'column', columns },
  note: 'Photograph: Despatch picture desk', ...extra });
// A table across two columns, at the foot of the column that cites it and the next one.
const panel = (id, model, extra) => ({ id, typeId: 'panel', kind: 'table', createdAt: 0,
  updatedAt: 0, table: { model }, placement: { position: 'bottom', columns: 2 }, ...extra });
// As many columns as the page has (or more) is page-wide: columns: 6 = span: 'page'.
```

## Ingredientes

**Ensina**

- [Três ou mais colunas](https://postext.dev/pt/docs/configuration.md#tipos-de-layout): layoutType 'multiple' divide o texto em columnCount colunas iguais, de 3 a 8, para jornais e revistas; fios, equilíbrio de colunas, notas de rodapé e flutuantes de largura total funcionam em todas elas.
- [Flutuantes em várias colunas](https://postext.dev/pt/docs/document-format.md#posicionamento): placement.columns coloca uma figura ou tabela sobre k colunas vizinhas, e o columns de um estilo de boxe (ou o atributo columns da cerca do bloco) faz o mesmo com um boxe flutuante: uma foto ocupando duas das cinco colunas de um jornal.

**Também usa**

- [Formatos de jornal](https://postext.dev/pt/docs/configuration.md#predefinições-de-tamanho-de-página)
- [Jornal dobrado (Folio)](https://postext.dev/pt/docs/configuration.md#visualizador-folio-configuração)
- [Fio entre colunas](https://postext.dev/pt/docs/configuration.md#fio-entre-colunas)
- [Boxes na largura da página](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Boxes flutuantes](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Colunas dentro de um boxe](https://postext.dev/pt/docs/document-format.md#columns)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Geometria por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Estilos de tabela com nome](https://postext.dev/pt/docs/configuration.md#estilos-de-tabela-nomeados)
- [Imagens nas células de tabela](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Boxes que se dividem ou ficam inteiros](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Equilíbrio de colunas](https://postext.dev/pt/docs/configuration.md#equilíbrio-de-colunas)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Quebras de página e de coluna](https://postext.dev/pt/docs/document-format.md#pagebreak)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Espaço vertical explícito](https://postext.dev/pt/docs/document-format.md#space)

**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), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`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), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`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**

- Newsreader (OFL-1.1), Playfair Display (OFL-1.1), Archivo Narrow (OFL-1.1)

## Preparo

### 1 · Seis colunas num formato standard

O código deste passo está [na resposta curta](https://postext.dev/pt/cookbook/broadsheet-six-columns.md#a-resposta-curta), mais acima. `page.sizePreset: 'broadsheet'` define o formato de 375 × 597 mm, e `layoutType: 'multiple'` com `columnCount: 6` corta os 351 mm de largura do texto em seis colunas iguais ([Tipos de diagramação](https://postext.dev/pt/docs/configuration.md#tipos-de-layout)). Cada uma tem (351 − 5 × 4) / 6 = 55,2 mm, o que em Newsreader de 9 pt comporta uns 38 caracteres, então o texto precisa de hifenização, de `minWordSpacing: 0.8` para manter os espaços abertos e de `maxJustifyTracking: 15` para distribuir uma linha frouxa entre as letras. O fio de coluna desenha uma linha de 0,4 pt em cada medianiz, interrompida onde um flutuante ou um boxe a cruza. Até o postext 1.17, o corpo tinha no máximo duas colunas, e por isso a [Nº 027](https://postext.dev/pt/cookbook/newspaper-front-page.md) compunha as quatro colunas de notas dentro de um boxe.

### 2 · Um flutuante em k colunas ocupa a primeira sequência de colunas vazias

```js
// script.js, linhas 254–283
const resources = [
  photo('quays', 'quays-1440.jpg', [1440, 1080], 4, {
    altText: 'A cobbled harbour quay at sunrise, with old rail tracks, three dockside cranes '
      + 'and brick warehouses beside calm water.',
    caption: '**The end of the line.** The Eastern Quays at sunrise. The trams will run on '
      + 'the old dock railway’s route past the bonded warehouses.' }),
  photo('barrier', 'barrier-1152.jpg', [1152, 1152], 2, {
    altText: 'Three engineers in orange jackets watch a steel flood gate hold back a high '
      + 'tide between two concrete towers.',
    caption: '**Holding.** Engineers watch the gate at 6.40 yesterday morning.' }),
  photo('terrace', 'terrace-1440.jpg', [1440, 1080], 4, {
    altText: 'A woman carries a box up the steps of a red-brick terraced house on an '
      + 'autumn street.',
    caption: '**Moving day** on Albion Terrace, where a two-bedroom flat now lets for £1,240.' }),
  photo('baths', 'baths-1536x768.jpg', [1536, 768], 3, {
    altText: 'A long Victorian swimming pool under an iron and glass roof, with one woman '
      + 'swimming and wooden cubicles along both sides.',
    caption: '**Forty lengths.** The Albert Baths, opened in 1894, on a weekday morning.' }),
  photo('stage', 'stage-1800.jpg', [1800, 1350], 5, {
    altText: 'Three actors in 1920s costume in a shipping office set on a theatre stage.',
    caption: '**Ledgers and lies.** The shipping office of *The Tally Clerk*.',
    note: 'Photograph: Corrington Playhouse' }),
  panel('funding', funding, { caption: '**Paying for the line**',
    note: 'Source: Corrington City Council, cabinet report, October 2026.' }),
  { ...panel('forecast', forecast(), { placement: { position: 'here' },
    caption: '**The next five days in Corrington**',
    note: 'High water at Salter’s Cut, Arle gate. Forecast: Despatch weather desk.' }) },
  ...Object.keys(SKY).map((id) => ({ id, typeId: 'photo', kind: 'svg', createdAt: 0,
    updatedAt: 0, svg: { fileId: `${id}.svg`, width: 240, height: 120 }, altText: id.slice(4) })),
];
```

`placement.columns` dá a uma foto ou tabela a largura em colunas, medianizes incluídas: quatro colunas da primeira página são 232,7 mm. O motor procura o espaço a partir da coluna em que o texto está, da esquerda para a direita. Um flutuante `top` ocupa o alto da primeira sequência de k colunas que ainda não têm texto e começam na mesma altura; um flutuante `bottom` ocupa o pé da coluna em que está o texto e das vazias depois dela. Assim, a tabela de financiamento, citada num parágrafo da coluna 3, fica no pé das colunas 3 e 4, e na [página 2](https://postext.dev/cookbook/broadsheet-six-columns/en/p02.webp?v=49f78219) o boxe de dados, aberto primeiro, ocupa as colunas 1 e 2, o que deixa as colunas 3 a 6 como a primeira sequência nivelada para a foto de quatro colunas aberta depois dele. Na ordem inversa, o boxe se empilharia sob a foto. Um k igual ao número de colunas da página (ou maior) ocupa a largura da página, o mesmo que `span: 'page'`.

### 3 · Boxes de manchete, boxes de dados e anúncios

```js
// script.js, linhas 90–120
const NO_PAD = { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) };
const head = { id: 'head', backgroundEnabled: false, padding: NO_PAD, // a headline and its deck
  titleStyle: { ...caps(8.5, 700, 'accent'), gap: mm(1.5) }, // the kicker: the fence's title
  marginBottom: pt(LEAD), keepTogether: true,
  body: { fontFamily: 'Newsreader', fontSize: pt(13), lineHeight: pt(16.5), italic: true,
    textAlign: 'left', firstLineIndent: pt(0) } };
const jump = { ...head, id: 'jump', marginTop: pt(LEAD), // a page-wide head under a 1 pt rule
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') },
  padding: { ...NO_PAD, top: mm(2.5) } };
const facts = { id: 'facts', background: col('tint'), keepTogether: true,
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
  padding: { top: mm(2.5), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { ...caps(8.5, 700, 'accent'), gap: mm(1.5) }, marginBottom: pt(LEAD),
  body: { fontFamily: 'Archivo Narrow', fontSize: pt(9), lineHeight: pt(11.5),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true } };
const weather = { ...facts, id: 'weather', keepTogether: true,
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  body: { ...facts.body, fontFamily: 'Newsreader', fontSize: pt(10.5), lineHeight: pt(14) } };
const ad = { id: 'ad', backgroundEnabled: false, keepTogether: true,
  border: { enabled: true, color: col('ink'), width: pt(0.75) },
  padding: { top: mm(5), right: mm(6), bottom: mm(5), left: mm(6) },
  titleStyle: { ...caps(7, 600, 'muted'), gap: mm(3) }, // "Advertisement"
  body: { fontFamily: 'Archivo Narrow', fontSize: pt(13), lineHeight: pt(17),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true } };
const sky = { id: 'sky', background: col('tint'), columnGap: mm(GUTTER), // the teaser strip
  padding: { top: mm(2.5), right: mm(3), bottom: mm(2.5), left: mm(3) }, marginBottom: pt(LEAD),
  body: { fontFamily: 'Archivo Narrow', fontSize: pt(9.5), lineHeight: pt(12),
    textAlign: 'left', firstLineIndent: pt(0) } };
const headline = (id, size, look = {}) => ({ id, fontFamily: 'Playfair Display', fontWeight: 700,
  fontSize: pt(size), lineHeight: pt(size * 1.06), marginTop: pt(0),
  marginBottom: pt(size * 0.22), ...look });
```

Um `:::callout` com `placement="top"` e `columns="4"` flutua sobre quatro colunas como uma foto. A manchete e a linha fina da matéria principal são um boxe assim, aberto antes do texto dela, então ele ocupa as colunas 1 a 4 e a foto se empilha embaixo. O boxe da manchete da segunda matéria vem depois da principal no texto, após um `:::columnbreak`: o texto já está na coluna 5 nesse ponto, e as colunas 5 e 6 são o primeiro par vazio. Cada anúncio é um boxe com `columns="6"`, na largura da página, aberto primeiro na sua página para que o pé fique reservado antes de chegar qualquer texto, e os títulos de continuação das páginas 2 e 4 são boxes na largura da página, como na Nº 027. `balancing.trailing: false` deixa cada página encher as colunas até o pé, já que aqui toda página fecha uma seção.

### 4 · O cabeçalho é o H1 da primeira página

```js
// script.js, linhas 46–66
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, 4)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 0.8)), size: { width: mm(30) } }));
const nameplate = { enabled: true, slot: { elements: [
  depth(9), // nine grid lines; the rules and the dateline hang from the foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -2), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.price}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(8, 600), align },
      { ...at('#foot', 'above', 0, -1.5), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 3, 0.7),
  text('name', '{titleText}', { fontFamily: 'Playfair Display', fontSize: pt(64),
    fontWeight: 900, lineHeight: 1, align: 'center' }, // (gotcha: design-lineheight-multiple)
  { ...at('#heavy', 'above', 0, -2.5), size: { width: 'fill' } }),
  ...ear('left', [['w-kicker', 'Weather', caps(7.5, 700, 'accent')],
    ['w-outlook', '{attr.outlook}', { fontFamily: 'Newsreader', fontSize: pt(9.5), italic: true,
      lineHeight: 1.25 }], ['w-temps', '{attr.temps}', sans(17, 700)]]),
  ...ear('right', [['t-kicker', 'Tides', caps(7.5, 700, 'accent')],
    ['t-times', '{attr.tides}', { fontFamily: 'Newsreader', fontSize: pt(9.5), italic: true,
      lineHeight: 1.25 }], ['t-height', '{attr.height}', sans(17, 700)]]),
] } };
```

`# The Corrington Despatch` usa o estilo de título `front`: o nome em Playfair Display Black de 64 pt, duas orelhas preenchidas com os atributos do título e a linha da data entre um fio fino e outro grosso. A abertura tem nove linhas da grade de altura. Na 1.18.0, uma abertura ocupa a coluna 1, então um flutuante de k colunas aberto logo depois dela começa na coluna 2; a faixa de chamadas com fundo de cor, um boxe na largura da página, abre uma nova faixa cuja primeira coluna está vazia, e a matéria principal ocupa as colunas 1 a 4.

### 5 · A página de opinião corre em cinco colunas

```js
// script.js, linhas 124–145
const flag = { enabled: true, slot: { elements: [
  depth(4),
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -2.5), size: { width: 'fill' } } },
  text('flag', '{titleText}', { fontFamily: 'Playfair Display', fontSize: pt(30),
    fontWeight: 900, lineHeight: 1 }, at('#bar', 'above', 0, -1.5)),
  text('tag', '{attr.tag}', { ...caps(8, 600, 'muted'), align: 'right' },
    { ...at('#bar', 'above', 0, -2), size: { width: 'fill' } }),
] } };
const FOLIO = 8; // mm from the top edge to the folio line, its rule 4.5 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso, −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...sans(10, 700), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7.5, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4.5),
      size: { width: mm(375 - 2 * MARGIN.side) } } }] };
const opinion = { id: 'opinion', advancedDesign: flag, // its section runs in five columns
  layout: { layoutType: 'multiple', columnCount: 5 } }; // 67 mm: a 47-character measure
```

`# Opinion {style="opinion"}` abre uma seção cujo estilo de título define `layout: { layoutType: 'multiple', columnCount: 5 }`. A seção mantém a medianiz e o fio de coluna do documento e só muda o número de colunas, então os editoriais, o artigo de opinião e as cartas correm numa medida de 67 mm, uns 47 caracteres. O boxe da manchete do artigo é `columns="3"`, aberto quando o editorial já começou na coluna 1, e por isso cai nas colunas 2 a 4 com a foto embaixo; duas linhas `:::columnbreak` mandam o artigo para a coluna 2 e as cartas para a coluna 5.

## 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/broadsheet-six-columns

### script.js

```js
// ═══ Postext Cookbook · Nº 133 · A broadsheet on six columns ════════════════════
// https://postext.dev/en/cookbook/broadsheet-six-columns
// Code: MIT · Text: original (CC BY 4.0) · Photos: generated (CC BY 4.0)
// Fonts: Newsreader, Playfair Display, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.18.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: newsprint, a cool ink and one blue
const palette = {
  ink: '#15171a', // text, headlines and the heavy rules
  paper: '#f7f5ef', // newsprint; also type reversed out of a band
  accent: '#164a72', // the paper's blue: kickers, section flags, the fact boxes' stripe
  tint: '#e6ebef', // the fact boxes
  rule: '#8e9196', // hairlines: the column rules, table rules
  muted: '#55595f', // bylines, credits, the folio line
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const MARGIN = { top: 16, bottom: 14, side: 12 }; // mm: a broadsheet's narrow, even margins
const GUTTER = 4; // mm: six columns of 55.2 mm on the 351 mm text width
const LEAD = 12.2; // pt: the baseline grid under 9 pt text (× 1.36)
const sans = (size, weight, look = {}) => ({ fontFamily: 'Archivo Narrow', fontSize: pt(size),
  fontWeight: weight, color: col('ink'), ...look });
const caps = (size, weight, colour = 'ink') => sans(size, weight, { color: col(colour),
  textTransform: 'uppercase', letterSpacing: pt(size * 0.14) });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('ink'), ...look, placement }); // (gotcha: overflow-ellipsis-default)
const rule = (id, under, weight, gap = 0) => ({ kind: 'rule', id, thickness: pt(weight),
  color: col('ink'), placement: { ...at(under, 'above', 0, -gap), size: { width: 'fill' } } });
// An empty box a whole number of grid lines deep sets each opener's depth, so no sliver of
// column is left beside it for the rule to run through (gotcha: column-rule-through-opener).
const depth = (lines) => ({ kind: 'box', id: 'depth', style: {}, placement: {
  ...at('container', 'top-left'), size: { width: 'fill', height: pt(lines * LEAD) } } });

// #region nameplate: the front page's H1 is the paper's name, between two ears
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, 4)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 0.8)), size: { width: mm(30) } }));
const nameplate = { enabled: true, slot: { elements: [
  depth(9), // nine grid lines; the rules and the dateline hang from the foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -2), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.price}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(8, 600), align },
      { ...at('#foot', 'above', 0, -1.5), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 3, 0.7),
  text('name', '{titleText}', { fontFamily: 'Playfair Display', fontSize: pt(64),
    fontWeight: 900, lineHeight: 1, align: 'center' }, // (gotcha: design-lineheight-multiple)
  { ...at('#heavy', 'above', 0, -2.5), size: { width: 'fill' } }),
  ...ear('left', [['w-kicker', 'Weather', caps(7.5, 700, 'accent')],
    ['w-outlook', '{attr.outlook}', { fontFamily: 'Newsreader', fontSize: pt(9.5), italic: true,
      lineHeight: 1.25 }], ['w-temps', '{attr.temps}', sans(17, 700)]]),
  ...ear('right', [['t-kicker', 'Tides', caps(7.5, 700, 'accent')],
    ['t-times', '{attr.tides}', { fontFamily: 'Newsreader', fontSize: pt(9.5), italic: true,
      lineHeight: 1.25 }], ['t-height', '{attr.height}', sans(17, 700)]]),
] } };
// #endregion

// #region answer: six equal columns, and floats that take k of them
const layout = { layoutType: 'multiple', columnCount: 6, gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
// A float across k columns takes the head of the first run of k empty columns that start
// level, counted left to right from the column the text is in, or the foot of that column
// and the empty ones after it. So the order of the fences places them:
//   :::callout{type="head" placement="top" columns="4"}  ← columns 1–4, the text not begun
//   ::resource{id="quays"}                              ← columns 1–4 again, under the box
//   …the lead runs in columns 1–4…
//   :::callout{type="head" placement="top" columns="2"}  ← columns 5–6: 1–4 hold text now
const photo = (id, file, [w, h], columns, extra) => ({ id, typeId: 'photo', kind: 'bitmap',
  createdAt: 0, updatedAt: 0, bitmap: { fileId: file, format: 'jpeg', width: w, height: h },
  placement: { position: 'top', span: 'column', columns },
  note: 'Photograph: Despatch picture desk', ...extra });
// A table across two columns, at the foot of the column that cites it and the next one.
const panel = (id, model, extra) => ({ id, typeId: 'panel', kind: 'table', createdAt: 0,
  updatedAt: 0, table: { model }, placement: { position: 'bottom', columns: 2 }, ...extra });
// As many columns as the page has (or more) is page-wide: columns: 6 = span: 'page'.
// #endregion

// #region boxes: headline boxes, fact boxes and advertisements
const NO_PAD = { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) };
const head = { id: 'head', backgroundEnabled: false, padding: NO_PAD, // a headline and its deck
  titleStyle: { ...caps(8.5, 700, 'accent'), gap: mm(1.5) }, // the kicker: the fence's title
  marginBottom: pt(LEAD), keepTogether: true,
  body: { fontFamily: 'Newsreader', fontSize: pt(13), lineHeight: pt(16.5), italic: true,
    textAlign: 'left', firstLineIndent: pt(0) } };
const jump = { ...head, id: 'jump', marginTop: pt(LEAD), // a page-wide head under a 1 pt rule
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') },
  padding: { ...NO_PAD, top: mm(2.5) } };
const facts = { id: 'facts', background: col('tint'), keepTogether: true,
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
  padding: { top: mm(2.5), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { ...caps(8.5, 700, 'accent'), gap: mm(1.5) }, marginBottom: pt(LEAD),
  body: { fontFamily: 'Archivo Narrow', fontSize: pt(9), lineHeight: pt(11.5),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true } };
const weather = { ...facts, id: 'weather', keepTogether: true,
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  body: { ...facts.body, fontFamily: 'Newsreader', fontSize: pt(10.5), lineHeight: pt(14) } };
const ad = { id: 'ad', backgroundEnabled: false, keepTogether: true,
  border: { enabled: true, color: col('ink'), width: pt(0.75) },
  padding: { top: mm(5), right: mm(6), bottom: mm(5), left: mm(6) },
  titleStyle: { ...caps(7, 600, 'muted'), gap: mm(3) }, // "Advertisement"
  body: { fontFamily: 'Archivo Narrow', fontSize: pt(13), lineHeight: pt(17),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true } };
const sky = { id: 'sky', background: col('tint'), columnGap: mm(GUTTER), // the teaser strip
  padding: { top: mm(2.5), right: mm(3), bottom: mm(2.5), left: mm(3) }, marginBottom: pt(LEAD),
  body: { fontFamily: 'Archivo Narrow', fontSize: pt(9.5), lineHeight: pt(12),
    textAlign: 'left', firstLineIndent: pt(0) } };
const headline = (id, size, look = {}) => ({ id, fontFamily: 'Playfair Display', fontWeight: 700,
  fontSize: pt(size), lineHeight: pt(size * 1.06), marginTop: pt(0),
  marginBottom: pt(size * 0.22), ...look });
// #endregion

// #region sections: inside pages open on a flag; the opinion page takes five columns
const flag = { enabled: true, slot: { elements: [
  depth(4),
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -2.5), size: { width: 'fill' } } },
  text('flag', '{titleText}', { fontFamily: 'Playfair Display', fontSize: pt(30),
    fontWeight: 900, lineHeight: 1 }, at('#bar', 'above', 0, -1.5)),
  text('tag', '{attr.tag}', { ...caps(8, 600, 'muted'), align: 'right' },
    { ...at('#bar', 'above', 0, -2), size: { width: 'fill' } }),
] } };
const FOLIO = 8; // mm from the top edge to the folio line, its rule 4.5 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso, −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...sans(10, 700), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7.5, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4.5),
      size: { width: mm(375 - 2 * MARGIN.side) } } }] };
const opinion = { id: 'opinion', advancedDesign: flag, // its section runs in five columns
  layout: { layoutType: 'multiple', columnCount: 5 } }; // 67 mm: a 47-character measure
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes,
  page: { sizePreset: 'broadsheet', dpi: 150, backgroundColor: col('paper'), // 375 × 597 mm
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.side),
      right: mm(MARGIN.side) } },
  layout,
  bodyText: { fontFamily: 'Newsreader', fontSize: pt(9), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3), indentAfterHeading: false,
    minWordSpacing: 0.8, // a 38-character measure: spaces never close up,
    maxJustifyTracking: 15, // and a little tracking comes before a wide one
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: 'Playfair Display', fontWeight: 700, color: col('ink'),
    balancing: { trailing: false }, // every page closes a section: fill each column to the foot
    levels: [
    // Restated (gotcha: headings-drop-h1-break): each section opens a page of its own.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, advancedDesign: flag },
    { level: 2, fontSize: pt(17), lineHeight: pt(19), marginTop: pt(LEAD), marginBottom: pt(3) },
    { level: 3, fontFamily: 'Archivo Narrow', fontSize: pt(9.5), lineHeight: pt(LEAD),
      marginTop: pt(LEAD * 0.5), marginBottom: pt(0) },
  ] },
  headingStyles: [
    { id: 'front', advancedDesign: nameplate, header: { elements: [] } }, // no folio line
    opinion,
    headline('lead', 50, { fontWeight: 800 }), headline('second', 25), headline('third', 30),
    headline('ad-title', 40, { fontWeight: 900 }), headline('ad-big', 60, { fontWeight: 900 }),
    headline('ad-huge', 88, { fontWeight: 900, lineHeight: pt(84) }), headline('ad-date', 32),
    headline('stat', 26, { marginTop: pt(LEAD * 0.5), marginBottom: pt(0) }), // a fact box's figure
  ],
  calloutStyles: [head, jump, facts, weather, ad, sky],
  paragraphStyles: [
    { id: 'byline', ...sans(8.5, 600), textAlign: 'left', firstLineIndent: pt(0),
      textTransform: 'uppercase' },
    { id: 'intro', fontSize: pt(11), lineHeight: pt(LEAD * 1.25), firstLineIndent: pt(0),
      fontWeight: 600, textAlign: 'left' }, // the drop intro: larger, bold and ragged
    { id: 'brief', firstLineIndent: pt(0), spaceBetween: pt(LEAD) }, // the digest's items
    { id: 'label', ...sans(10, 700, { color: col('accent'), textTransform: 'uppercase' }),
      lineHeight: pt(LEAD), firstLineIndent: pt(0),
      textAlign: 'left', marginBottom: pt(LEAD * 0.5) }, // In brief, Letters, What's on
    { id: 'flush', firstLineIndent: pt(0) },
    { id: 'turn', ...sans(8.5, 700), textAlign: 'right', firstLineIndent: pt(0) },
    { id: 'ad-deck', fontFamily: 'Newsreader', fontSize: pt(16), lineHeight: pt(20),
      italic: true, firstLineIndent: pt(0), textAlign: 'left' },
    { id: 'imprint', ...sans(7, 400), color: col('muted'), textAlign: 'left',
      firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  captionStyle: { fontFamily: 'Archivo Narrow', fontSize: pt(8.5), gap: mm(1.5),
    labelBold: true, color: col('ink'), note: { fontSize: pt(7), color: col('muted') } },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Archivo Narrow',
    headerFontSize: pt(8), bodyFontFamily: 'Archivo Narrow', bodyFontSize: pt(8.5),
    bodyColor: col('ink'), cellPadding: mm(1.2) },
  header, footer: { elements: [] }, // the folio line sits at the head of inside pages
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('photo', 'Photo'),
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })];
// #region art: the two tables as data, and the forecast's sky icons drawn in code
const cell = (content, extra = {}) => ({ content, ...extra });
const th = (content, align = 'left') => cell(content, { isHeader: true, align });
const funding = { headerRowCount: 1, columnWidths: [62, 18, 20], rows: [
  [th('Source'), th('£m', 'right'), th('Share', 'right')],
  ...[['National transport fund', 318], ['City council borrowing', 212], ['Port authority', 64],
    ['Eastern Quays developers', 46], ['**Total**', 640]].map(([who, m]) => [cell(who),
    cell(m === 640 ? '**640**' : String(m), { align: 'right' }),
    cell(`${Math.round((100 * m) / 640)}%`, { align: 'right' })])] };
const cloud = (x, y, k, fill) => `<path fill="${fill}" d="M${x} ${y}h${60 * k}a${16 * k} ${16 * k} `
  + `0 0 0-6-31a${22 * k} ${22 * k} 0 0 0-40-6a${15 * k} ${15 * k} 0 0 0-14 37z"/>`;
const sun = (x, y, r) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${palette.accent}"/>`
  + [...Array(8)].map((_, i) => { const a = (i * Math.PI) / 4; const c = Math.cos(a);
    const s = Math.sin(a); return `<path d="M${(x + c * r * 1.35).toFixed(1)} `
      + `${(y + s * r * 1.35).toFixed(1)}L${(x + c * r * 1.75).toFixed(1)} `
      + `${(y + s * r * 1.75).toFixed(1)}" stroke="${palette.accent}" stroke-width="4"/>`;
  }).join('');
const rain = [0, 1, 2].map((i) => `<path d="M${98 + i * 18} 92l-6 16" stroke="${palette.accent}" `
  + 'stroke-width="4"/>').join('');
const svg = (body) => '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="120" '
  + `viewBox="0 0 240 120">${body}</svg>`;
const SKY = { // 240 × 120 units: a symbol per kind of day
  'sky-sunny': svg(sun(120, 60, 24)),
  'sky-bright': svg(sun(100, 48, 20) + cloud(110, 96, 1, palette.rule)),
  'sky-cloudy': svg(cloud(80, 90, 1.2, palette.rule)),
  'sky-rain': svg(cloud(84, 80, 1.1, palette.muted) + rain),
};
function forecast() { // Thursday to Monday: sky, outlook, °C, wind, rain, sun, high water
  const days = [['Thu', 'rain', 'Rain from midday, clearing late', 14, 9, 'W 18', 12, '7.31',
    '18.12', '7.21 · 19.40'], ['Fri', 'bright', 'Bright and blustery', 13, 6, 'NW 22', 1, '7.33',
    '18.10', '8.02 · 20.21'], ['Sat', 'sunny', 'Sunny, a cold wind', 12, 5, 'N 14', 0, '7.35',
    '18.08', '8.44 · 21.03'], ['Sun', 'cloudy', 'Cloudy and dry', 12, 7, 'NE 8', 0, '7.36',
    '18.05', '9.27 · 21.48'], ['Mon', 'sunny', 'Frost, then sunshine', 13, 2, 'E 6', 0, '7.38',
    '18.03', '10.13 · 22.37']];
  const right = (v) => cell(String(v), { align: 'right' });
  return { headerRowCount: 1, columnWidths: [7, 15, 25, 6, 6, 8, 8, 8, 8, 13], rows: [
    [th('Day'), th(''), th('Outlook'), th('High', 'right'), th('Low', 'right'),
      th('Wind mph', 'right'), th('Rain mm', 'right'), th('Sunrise', 'right'),
      th('Sunset', 'right'), th('High water', 'right')],
    ...days.map(([day, sky, text, hi, lo, wind, mm, rise, set, tide]) => [cell(`**${day}**`),
      cell('', { image: { resourceId: `sky-${sky}` } }), cell(text), right(`${hi}°`),
      right(`${lo}°`), right(wind), right(mm), right(rise), right(set), right(tide)])] };
}
// #endregion
// #region pictures: each photo names how many columns it takes; the fences place it
const resources = [
  photo('quays', 'quays-1440.jpg', [1440, 1080], 4, {
    altText: 'A cobbled harbour quay at sunrise, with old rail tracks, three dockside cranes '
      + 'and brick warehouses beside calm water.',
    caption: '**The end of the line.** The Eastern Quays at sunrise. The trams will run on '
      + 'the old dock railway’s route past the bonded warehouses.' }),
  photo('barrier', 'barrier-1152.jpg', [1152, 1152], 2, {
    altText: 'Three engineers in orange jackets watch a steel flood gate hold back a high '
      + 'tide between two concrete towers.',
    caption: '**Holding.** Engineers watch the gate at 6.40 yesterday morning.' }),
  photo('terrace', 'terrace-1440.jpg', [1440, 1080], 4, {
    altText: 'A woman carries a box up the steps of a red-brick terraced house on an '
      + 'autumn street.',
    caption: '**Moving day** on Albion Terrace, where a two-bedroom flat now lets for £1,240.' }),
  photo('baths', 'baths-1536x768.jpg', [1536, 768], 3, {
    altText: 'A long Victorian swimming pool under an iron and glass roof, with one woman '
      + 'swimming and wooden cubicles along both sides.',
    caption: '**Forty lengths.** The Albert Baths, opened in 1894, on a weekday morning.' }),
  photo('stage', 'stage-1800.jpg', [1800, 1350], 5, {
    altText: 'Three actors in 1920s costume in a shipping office set on a theatre stage.',
    caption: '**Ledgers and lies.** The shipping office of *The Tally Clerk*.',
    note: 'Photograph: Corrington Playhouse' }),
  panel('funding', funding, { caption: '**Paying for the line**',
    note: 'Source: Corrington City Council, cabinet report, October 2026.' }),
  { ...panel('forecast', forecast(), { placement: { position: 'here' },
    caption: '**The next five days in Corrington**',
    note: 'High water at Salter’s Cut, Arle gate. Forecast: Despatch weather desk.' }) },
  ...Object.keys(SKY).map((id) => ({ id, typeId: 'photo', kind: 'svg', createdAt: 0,
    updatedAt: 0, svg: { fileId: `${id}.svg`, width: 240, height: 120 }, altText: id.slice(4) })),
];
// #endregion
const front = String.raw`---
title: "The Corrington Despatch"
author: "The Corrington Despatch"
publishDate: "Wednesday 14 October 2026"
---

# The Corrington Despatch {style="front" issue="No. 48,213" price="£2.20" outlook="Bright, then showers from the west" temps="14° · 7°" tides="High water at Salter’s Cut 7.21 and 19.40" height="5.9 m"}

:::callout{type="sky" span="page"}
:::columns{count=4 breaks="2,3,4"}
**Rents up 9% in a year.** A room near the university now costs £640 a month. *Page 2*

**Save the Baths.** Why the city should keep its Victorian pool open. *Page 3*

**Showers, then sun.** A wet Thursday, and the highest tide of the year on Friday. *Page 4*

**The Tally Clerk.** A dockside drama at the Playhouse, reviewed. *Page 4*
:::
:::

:::callout{type="head" placement="top" columns="4" title="Transport"}
## Tram to the docks wins council vote after six-hour debate {style="lead"}

Councillors back the £640m Harbour Line by 41 votes to 22. Work on the 9.4 km route starts next autumn, and the first passengers ride in 2031
:::

::resource{id="quays"}

:::callout{type="ad" placement="bottom" columns="6" title="Advertisement"}
## The Despatch Harbour Run {style="ad-title"}

**Ten kilometres along the quays on Sunday 1 November.** From the Corn Hall down Market Street, past the warehouses to Salter’s Cut and back, before the tram works close the route. Start at ten.

Enter at our front desk, 12 Bridge Street, by Friday 23 October. £18, or £8 for under-16s. Every runner gets a medal and a bacon roll, and every pound of the entry fees goes to the Arle Valley lifeboat.
:::

:::paragraphs{style="byline"}
By Helen Achterberg · Transport correspondent
:::

:::paragraphs{style="intro"}
Corrington will build its first tram line in seventy years. After a debate that ran from two in the afternoon until ten past eight, the city council voted 41 to 22 last night to approve the Harbour Line and to borrow £212 million towards its cost.
:::

The line will run 9.4 kilometres from Northgate station, through the city centre and along the old dock railway to the Eastern Quays, with fourteen stops. Twenty-two trams will run every six minutes at the busiest times of day, and the trip from end to end will take 27 minutes. The number 7 bus takes 48 minutes on a good day.

The vote ends a fight that began in 2019, when the port authority offered the city the trackbed of its disused railway for one pound. Two earlier plans, a guided busway in 2021 and a cable car in 2023, failed for lack of money. This one has a promise of £318 million from the national transport fund, which expires if construction has not begun by March 2028.

“We have talked about this for longer than some of our councillors have been alive,” said the council leader, Ines Moberly, closing the debate. “Tonight we stop talking. The quays will have six thousand new homes by the end of the decade, and those families will not all be able to drive into town.”

### Six hours in the chamber

The public gallery was full by one o’clock. Forty-three members of the public had asked to speak, the most the clerk’s office has recorded, and the chair allowed each of them three minutes. Most came from the Eastern Quays, where the line will end, and from Market Street, where it will run down the middle of the road between the Corn Hall and the bus station.

Traders on Market Street fear two years of roadworks outside their doors. Neil Fairweather, who has sold bicycles there since 1994, said the council’s offer of a £2,000 grant for each shop would “pay for about three weeks of the takings we will lose”. The council has since agreed to keep one lane open to deliveries and to dig the street in sections of no more than 150 metres at a time.

The opposition group voted against the plan as a whole. Its leader, Graham Tulley, said the borrowing would cost the city £9.6 million a year in interest for thirty years, “about what we spend on every library and swimming pool together”. He proposed a cheaper electric bus route on the same line, which lost by 39 votes to 24.

Residents of the Eastern Quays spoke mostly in favour. Amira Haddad, who chairs the Quays Residents’ Association, said that 2,300 people already live in the converted warehouses and the new flats behind them, with one bus an hour after seven in the evening. “My neighbours work shifts at the hospital and the container port,” she said. “They walk home along the quay in the dark because the last bus has gone.”

### Who pays

The city’s share is the largest it has borrowed for a single project :ref{id="funding" text="(table)"}. Officers expect fares to cover the running costs from the third year, and the council will charge the developers of the Eastern Quays £46 million towards the stops that serve their land. The port authority will pay £64 million, most of it in land and in the rebuilding of the swing bridge at Salter’s Cut, which will carry the trams over the inner basin.

Paul Okonedo, the council’s finance director, told councillors that the borrowing would be repaid from fares, from a levy on the new car parks at the Eastern Quays and from the business rates of the shops and offices planned beside the stops.

Work starts in September 2027 with the depot at Northgate, where the trams will be kept and cleaned. The track along the quays comes next, because the old railway is already clear, and the city centre last, from the spring of 2029. The council has set aside £31 million for delays and higher costs, about one pound in every twenty.

The first of the trams are due for testing on the quays in the autumn of 2030. Fares will be the same as the buses, £2 for a single ride anywhere on the network, with a free change between tram and bus within the hour.

### What happens next

The council will publish the full timetable of the works on its website on Friday, street by street. Residents along the route will receive a letter by the end of the month. A drop-in exhibition opens at the Corn Hall on Monday 19 October, from ten until seven every weekday for a fortnight. The opposition has said it will ask for the decision to be reviewed by the council’s scrutiny committee, which could delay the first contracts by up to six weeks but cannot overturn the vote.

:::paragraphs{style="turn"}
Market Street traders count the cost, page 2
:::

:::columnbreak

:::callout{type="head" placement="top" columns="2" title="Environment"}
## Arle gate holds back the tide in its first full test {style="second"}

Engineers close the £84m barrier for six hours, as autumn’s highest tide reaches the city
:::

::resource{id="barrier"}

:::paragraphs{style="byline"}
By Tomasz Weller
:::

:::paragraphs{style="flush"}
The Arle flood gate closed for its first full test yesterday morning, and the river behind it stayed where it was. At high water, at 6.52, the tide outside the gate stood 1.8 metres higher than the river on the city side.
:::

The gate, a single steel leaf 38 metres wide and 11 metres tall, took eleven minutes to lower from its frame between the two towers at Salter’s Cut. It weighs 640 tonnes and moves on four chains, each link the size of a man’s forearm. It rose again at 12.30, once the tide had fallen. About two hundred people watched from the quay wall and the footbridge, some of them in dressing gowns, and a cheer went up when the gate touched the riverbed.

The barrier was built after the floods of January 2021, when the river came over the quay wall in four places and filled the cellars of 1,100 homes in Old Town and Wharfside. It cost £84 million, two thirds of it from the national flood defence budget, and was finished in June, five months late.

“A real tide is the only test that counts,” said Ruth Adebayo, who runs the barrier for the river authority. “We have closed it on calm days. Today the sea pushed back, and it held.”

The gate will be closed for every tide forecast above 6.2 metres, about a dozen times a year at first. As the sea rises, the authority expects to close it forty times a year by 2070.

:::callout{type="facts" placement="bottom" columns="2" title="The Arle gate"}
:::columns{count=2 breaks="4"}
**38 m** wide, the full width of the river at Salter’s Cut

**11 m** tall, and 640 tonnes of steel

**11 minutes** to lower it on four chains

**£84m** to build, two thirds from the national flood budget

**12 closures** a year expected now, for tides over 6.2 metres

**40 closures** a year by 2070, as the sea rises
:::
:::

Boats in the inner basin were held for six hours. Three fishing boats waited outside the gate, and the ferry to Hollin Point ran from the outer pier. Andrew Pell, who skippers the trawler *Morning Star*, said the wait had cost him a day’s fishing. “I’m not complaining,” he said. “My mother’s house was one of the eleven hundred. I’ll wait six hours if it keeps her kitchen dry.” The authority will give a week’s notice of each closure on its website and by text message to river users who sign up.

:::paragraphs{style="turn"}
More river news, page 2
:::

:::columnbreak

:::paragraphs{style="label"}
In brief
:::

:::paragraphs{style="brief"}
**Car park fees frozen.** Parking at Corrington General will stay at £1.80 an hour until April, the hospital trust said yesterday, after 4,100 people signed a petition against a rise to £2.50. Blue badge holders and the parents of children on the wards park free.

**Ferry back on Saturdays.** The Hollin Point ferry will run on Saturdays again from 24 October, every forty minutes from 8.20 until 18.40. A single costs £3.40.

**Later library.** Westfield library will open until eight on Tuesdays and Thursdays from next week, after the Friends of Westfield Library agreed to staff the evenings.

**Quay Road closed.** Quay Road will close between the swing bridge and Wharf Lane from Monday 19 October for three weeks while a water main is replaced. Buses 7 and 12 will run along Chandlers Row instead.

**A record seal count.** Volunteers counted 212 grey seals on the sandbanks off Hollin Point on Sunday, the most since the count began in 1998. Forty-one were pups.

**Market stays late.** The Corn Hall market will stay open until nine every Friday in November and December, with forty stalls and a brass band from the Salvation Army on the first night.

**Choir wins in Cardiff.** The Corrington Youth Choir took first prize in the under-18 section of a national choral competition in Cardiff on Saturday, singing three pieces by its own director, Lena Fairclough. The 34 singers came home on the overnight coach.

**Blood donors wanted.** The blood service will hold sessions at St Mary’s church hall on 20 and 21 October, from one until seven. New donors can book a place by telephone or turn up on the day.
:::
`; // content.en.md: the front page
const city = String.raw`# City {tag="Corrington and the Arle valley"}

:::callout{type="jump" span="page" title="Harbour Line · continued from page 1"}
## Market Street traders count the cost of two years of digging {style="second"}
:::

:::paragraphs{style="flush"}
For the shops of Market Street, the council’s vote means two years of trenches, fences and diverted buses outside their windows, and a promise that it will be worth it in the end.
:::

“The tram will be lovely for the people who come after us,” said Margaret Imrie, who has run the wool shop at number 41 since 1987. “I would like to still be here when it arrives.” She has asked the council whether it will lower her business rates while the street is dug up. The answer, so far, is that it is looking at it.

The council’s plan splits the street into eight sections of about 150 metres. Each will be closed to traffic for twelve weeks at most, and every shop will keep a pavement and a front door. Deliveries will come in through the back lanes before ten in the morning, and the council will pay for extra bins and for signs telling shoppers that the street is open.

Shops on the streets of other cities lost between a fifth and a third of their custom while tram lines were laid outside their doors, according to a study for the council by the university’s business school. The same study found that rents and footfall in those streets were higher five years after the trams arrived than before the work began.

Not every trader is worried. Sunil Mistry, whose family has run the hardware shop by the Corn Hall for three generations, voted for the line in the public consultation. “My grandfather used to say the old trams brought him half his customers,” he said. “They took the rails up in 1956. I have been waiting for them to put them back.”

:::callout{type="jump" span="page" title="Housing"}
## Rents rise 9% in a year as students return {style="second"}

The average room near the university now lets for £640 a month, and a two-bedroom flat in Westfield for £1,240
:::

:::callout{type="facts" placement="top" columns="2" title="What a home costs"}
### £640 {style="stat"}

a month for a room in a shared house near the university, up 9% in a year

### £1,240 {style="stat"}

a month for a two-bedroom flat in Westfield

### 1,860 {style="stat"}

homes let to visitors by the night this summer, against 610 in 2022

### 312 {style="stat"}

students began the term without a room of their own

### 900 {style="stat"}

beds in the new halls on Gasworks Lane, from September 2028
:::

::resource{id="terrace"}

:::callout{type="ad" placement="bottom" columns="6" title="Advertisement"}
## Albion & Pryce {style="ad-big"}

:::paragraphs{style="ad-deck"}
Homes to let in Corrington since 1926. Every flat inspected by us, every deposit protected.
:::

:::space{lines=5}

:::columns{count=3 breaks="5,9"}
### £1,240 a month {style="ad-date"}

**Westfield.** Two bedrooms in a Victorian terrace, ten minutes’ walk from the university. Garden, gas central heating. From 1 November.

### £895 a month {style="ad-date"}

**Old Town.** One bedroom over a bakery on Ropewalk, with a view of the river from the kitchen.

### £1,650 a month {style="ad-date"}

**Harbourside.** Three bedrooms on the fourth floor of the Wool Exchange, with a lift and a parking space.

### £720 a month {style="ad-date"}

**Northgate.** A furnished studio by the station, bills included.

### £980 a month {style="ad-date"}

**Hollin Point.** A fisherman’s cottage with two bedrooms, five minutes from the ferry.

### 22 Castle Street {style="ad-date"}

Open six days a week, from nine until half past five. Ask for Joanna or Kwame.
:::

:::space{lines=6}

:::paragraphs{style="ad-deck"}
Thinking of letting your home? We will tell you what it would fetch, free, within a week.
:::
:::

:::paragraphs{style="byline"}
By Priya Lindqvist · Housing reporter
:::

:::paragraphs{style="flush"}
Rents in Corrington have risen by 9% in a year, more than twice as fast as wages, as the city’s 23,000 students returned to find fewer rooms to let than at any time since the university opened its third campus in 2014.
:::

The average room in a shared house within two miles of the university now costs £640 a month, up from £585 last October, according to figures published yesterday by the city’s housing office. A two-bedroom flat in Westfield, where most second-year students live, lets for £1,240.

Letting agents say the number of homes on their books has fallen by about a quarter in three years, as landlords sell up or move their flats to short holiday lets. The council counted 1,860 homes in the city let to visitors by the night this summer, against 610 in 2022.

Hanna Okafor, the president of the students’ union, said more than 300 first-year students had started the term without a room of their own. Some are sleeping on friends’ floors, and about forty are staying in a youth hostel on the quay, paid for by the university until the end of November. Others have taken rooms in Kirkham and Elsby, two towns along the railway line, where a room costs about £450. “We have students commuting from Hollin Point on the ferry,” she said. “That is an hour each way.”


The university has begun work on halls for 900 students on the site of the old gasworks in Gasworks Lane, but they will not open until September 2028. The council is considering a licence for holiday lets, which would cost owners £350 a year and limit each home to ninety nights.

Landlords say the rise reflects their own costs. Duncan Fairley, who lets 34 flats in Westfield and chairs the Corrington Landlords’ Association, said mortgage rates, insurance and the cost of meeting new energy rules had all gone up faster than rents. “Nobody is getting rich on a student house in Westfield,” he said. “The ones who could have sold.”

Not every landlord agrees. Rachel Obi, who lets two houses in Albion to nurses from the General, said she had kept her rents level for three years. “I would rather have tenants who stay than squeeze the last fifty pounds out of them,” she said.

The council’s housing committee will discuss the licence scheme on 3 November. Residents can give their views on its website or at the Corn Hall exhibition on the tram line, where housing officers will have a stand.

### Sharing a kitchen with nine

Some of those who found a room have found it in houses the council says are too full. Its housing officers inspected 140 shared houses in Westfield and Albion in September and found 31 with more tenants than their licence allows. One, on Mafeking Street, had ten students sharing a kitchen and a single bathroom. The landlord has been fined £12,000.

Students who think their house is overcrowded or unsafe can ask the council to inspect it, and their name will not be passed to the landlord. The students’ union runs a free advice session every Wednesday afternoon in the union building, where a housing lawyer checks tenancy agreements before they are signed.

The council’s inspectors say the worst houses are rarely the ones students complain about. “The tenants who need us most are often the ones who are afraid to call,” said its chief housing officer, Grace Adeyemi. “They think they will lose the room, and they have nowhere else to go.” Her team will knock on the door of every licensed shared house in Westfield before Christmas, the first time it has done so since the licences were introduced in 2017.
`; // content.city.en.md: page 2
const views = String.raw`# Opinion {style="opinion" tag="Leaders, comment and letters · five columns"}

:::callout{type="ad" placement="bottom" columns="6" title="Advertisement"}
## The Corrington Philharmonic {style="ad-huge"}

:::space{lines=2}

:::paragraphs{style="ad-deck"}
Winter at the Guildhall. Four Saturdays and Thursdays of music by the river, conducted by Ewa Lindgren
:::

:::space{lines=2}

:::columns{count=4 breaks="3,5,7"}
### 22 October {style="ad-date"}

Brahms, Symphony No. 4, and the Elgar Cello Concerto with Mira Halvorsen

### 7 November {style="ad-date"}

Music for the river: Handel’s *Water Music* and a new piece by Tomas Reyne, written for the Arle gate

### 27 November {style="ad-date"}

Sibelius, Symphony No. 2, and the Grieg Piano Concerto with Daniel Okoro

### 12 December {style="ad-date"}

Carols by candlelight with the Corrington Youth Choir and the Guildhall organ
:::

:::space{lines=2}

:::space{lines=6}

:::paragraphs{style="ad-deck"}
A season ticket for all four concerts costs £40, and brings a free programme and a glass of wine at each interval.
:::

:::space{lines=10}

**Tickets from £12, under-26s £5.** Box office at the Guildhall, Castle Street, from ten until six, Monday to Saturday. All concerts begin at half past seven, with a free talk at half past six.
:::

:::paragraphs{style="label"}
The Despatch view
:::

:::callout{type="head" placement="top" columns="3" title="Comment"}
## Close the Albert Baths and the city loses more than a pool {style="third"}

Ruth Calloway has swum at the baths since 1971 and chairs the Friends of the Albert Baths
:::

::resource{id="baths"}

### A line worth the wait

Corrington has argued about a tram since before most of its councillors were elected, and last night it stopped arguing. This newspaper has backed the Harbour Line since the port authority offered its old railway in 2019, and we are glad the council found the nerve to approve it.

The case is simple. Six thousand homes will be built on the Eastern Quays by 2030. The roads into the centre are full at eight in the morning already, and the number 7 bus takes three quarters of an hour to cover four miles. A tram every six minutes, on its own track, will carry those families to work, to school and to the hospital without adding a car to Market Street.

The risks are real. The council is borrowing more than it ever has, and its record on large projects is mixed: the leisure centre at Northgate opened two years late and £6 million over budget. Councillors should publish the costs every quarter, in plain figures, and let the scrutiny committee read the contracts.

The traders of Market Street deserve more than £2,000 each and a promise. A rates holiday while their street is dug up would cost the city little, and keep open the shops the tram will need when it comes. The council found £31 million for contingencies; it can find a few hundred thousand for the street that will carry the line through the heart of the city.

The opposition’s cheaper bus route deserved a hearing, and it got one. But a bus lane can be taken away by the next council that wants the road back, and the developers of the Eastern Quays know it. Rails in the road are a promise that lasts, and that promise is what persuades a builder to put up homes beside them.

### Count the empty rooms

Three hundred students began term without a room of their own. The blame is shared by a university that took more students than it could house and by a city that let 1,860 of its homes become holiday flats.

The licence the council will discuss next month is modest. It should pass, and the money it raises should pay for inspectors in Westfield, where the council found one house in five too full. A city that wants students to stay after they graduate should start by giving them somewhere decent to live while they study. The university, for its part, should not admit more students next year than it can house, however welcome their fees. Its students should not have to learn, in their first week, what a damp mattress smells like.

:::columnbreak

### By Ruth Calloway

The first time I swam at the Albert Baths I was seven, and my father held me up by the chin at the shallow end while the light came down through the glass roof in long green bars. Last month I swam my usual forty lengths there on a Tuesday morning, and I counted three other swimmers.

That is the council’s case for closing the baths, and I understand it. The Albert costs £410,000 a year to run and takes £96,000 in tickets. Its roof leaks in eleven places. The boiler dates from 1964, and the engineers who inspected it in the spring said it could fail in any winter. Repairing the building properly would cost £7.2 million, and the council has not got it.

But the council’s figures leave out who swims there and when. On weekday mornings the pool belongs to the over-sixties, who come by bus from Old Town and Wharfside because the new leisure centre at Northgate is two buses away. On Saturday afternoons it belongs to the swimming club, which has taught 4,000 children to swim since the war. The leisure centre’s pool is booked for lessons every evening until 2028.

The baths are also the last public building in the city that its people built for themselves. The money was raised by penny subscription in 1894, after a summer in which eleven children drowned in the docks. The names of the subscribers are painted on boards in the entrance hall, among them my great-grandfather, who gave two shillings.

Other towns have kept their old baths open by giving them to a trust that can raise money the council cannot: from the lottery, from charities and from the people who swim there. The Friends of the Albert Baths have 1,340 members, a business plan checked by an accountant and promises of £410,000 towards the first stage of repairs, which would fix the roof and replace the boiler.

We are asking the council for one thing: a lease of thirty years at a peppercorn rent, so that a trust can apply for the money. If we cannot raise it in five years, the building comes back to the city, and the council can do what it likes with it.

The cabinet decides on 5 November. Until then the baths are open every day from seven, and I would ask every councillor to come for a swim before they vote. The water is warmer than they think.

If they come on a Tuesday, they will meet the women of the half-past-seven club, who have swum together every week since 1979 and have buried four of their members in that time. Ask them what the baths are for. They will not mention the boiler. They will tell you about the friends they made in the water, the widowers who learnt to swim at seventy, and the morning in 1987 when the roof let in snow and they swam anyway. A city does not get places like that back once they are gone, and no leisure centre on a ring road, however clean its changing rooms, will ever take their place in the life of Wharfside.

:::columnbreak

:::paragraphs{style="label"}
Letters
:::

:::paragraphs{style="brief"}
**Not in my town’s name.** I have paid my council tax for 41 years and never once asked for a tram. Fix the potholes on Chandlers Row first, and then we can talk about borrowing £212 million. *Geoffrey Harland, Northgate*

**A tram, at last.** My daughter works night shifts at the General and walks home from the bus station along the quay. A tram that runs until midnight will let me sleep. Thank you to the 41 councillors who voted for it, and to the officers who worked on the plan for seven years. *Agnieszka Moran, Eastern Quays*

**The swimmers of the Albert.** I learnt to swim at the Albert Baths in 1958, and so did my children and my grandchildren. If the council closes it, where will the children of Wharfside learn? Not at Northgate, two buses and £4.80 away. *Patricia Doyle, Wharfside*

**Room for students.** I let two rooms in my house to students from the university, as my mother did before me. They have been quiet, tidy and good company. More owners with a spare room should do the same. *Hamid Siddiqui, Albion*

**Count the cyclists.** Your report on the tram line did not mention bicycles once. Will there be room for them on board, and will the rails on Market Street be safe to ride across in the wet? I have seen what tram tracks do to a front wheel. *Owen Bracewell, Old Town*

**A word for the ferry.** Thank you for reporting the return of the Saturday ferry. The crew of the *Hollin Belle* have kept it running through two winters on a council grant that has not risen since 2019. They deserve the city’s thanks. *Moira Kennedy, Hollin Point*

**Seals and dogs.** Please remind dog walkers on Hollin Point that the seals have pups on the sandbanks until December. Keep dogs on a lead below the high-water mark, and give the seals at least fifty metres. *Imogen Clarke, Hollin Point*

**Thanks to the lifeboat.** It reached my son’s capsized dinghy in nine minutes on Sunday. *Leon Marsh, Wharfside*

**Write to us** at 12 Bridge Street, Corrington, or hand your letter in at the front desk. Letters may be shortened, and we cannot print letters that arrive without a name and an address.
:::
`; // content.opinion.en.md: page 3
const back = String.raw`# Arts & Weather {tag="The Playhouse, what’s on and the week ahead"}

:::callout{type="jump" span="page" title="Theatre · The Playhouse"}
## A shipping clerk’s small lies keep the Playhouse holding its breath {style="third"}

*The Tally Clerk*, a new play by Maud Ellery, runs until 31 October. Four stars out of five
:::

::resource{id="stage"}

:::callout{type="weather" placement="bottom" columns="6" title="Weather"}
## Showers clear in time for Friday’s high tide {style="second"}

:::paragraphs{style="flush"}
A band of rain crosses the valley from the west during Thursday afternoon, with up to 15 mm on the hills above Elsby. It clears by Friday morning, when the highest tide of the year, 6.4 metres, reaches Salter’s Cut at 8.02 and the Arle gate will close for about four hours. The weekend is drier and colder, with a touch of frost in the valley on Monday morning.
:::

::resource{id="forecast"}
:::

:::paragraphs{style="byline"}
Reviewed by Daniel Ashworth
:::

:::paragraphs{style="flush"}
In the autumn of 1924, a tally clerk at the Corrington Steam Packet Company noticed that the bales of wool going out on the Thursday boat weighed less on the quay than they did in the ledger. He told nobody for three weeks. Maud Ellery’s new play, which opened at the Playhouse on Saturday, lives inside those three weeks, and it is the best thing the theatre has staged in years.
:::

The clerk, Arthur Penhale, is played by Jonah Rees as a man who has spent his life being careful and has only now understood what it has cost him. He earns two pounds a week. His wife, offstage and ill, needs a doctor he cannot pay for. The missing wool is worth more than his wages for a year, and the man who is stealing it, the company’s chief clerk, has noticed that Arthur has noticed.

Ellery wrote the play from a story her grandmother told her, and the dialogue has the sound of people who have worked together for twenty years and do not need to finish their sentences. The chief clerk, Mr Gethin, is all courtesy: he brings Arthur tea, asks after his wife and offers, in the second act, to lend him the doctor’s fee. Owen Mallory plays him with a smile that never reaches his eyes, and the scene in which he counts out the five pound notes on Arthur’s desk is the most frightening thing on a Corrington stage this year.

Between them sits the typist, Edie Shaw, who sees everything and says almost nothing. Freya Lindell gives her a stillness that the play needs; when she does speak, in the last ten minutes, the audience on Saturday gasped. I will not say what she says.

Ruth Okonkwo’s set is a shipping office in dark oak and green baize, with a tall window over the quay through which the light changes from morning to dusk across the two acts. Cranes swing past it, and once, during a long silence, a ship’s horn sounds in the dock. The sound design by Kit Abara is so precise that the creak of Arthur’s chair tells you what he has decided before he says it.

The play is not perfect. The first act runs fifteen minutes too long, and a scene in a public house, the only one outside the office, adds little but a song. The ending, which I suspect Ellery rewrote during rehearsals, ties up one thread too many. These are small faults in a play that trusts its audience to listen.

Rees carries the evening. He is on stage for almost all of its two hours and forty minutes, and much of the time he is doing nothing but adding up columns of figures while other people talk. Watch his pen. It slows when Gethin comes into the room, stops when Edie looks up from her typewriter, and in the final scene does not move at all. It is the most careful piece of acting I have seen at the Playhouse since the theatre reopened in 2019, and the quietest.

It is also, quietly, a play about this city. The Steam Packet Company’s offices stood on the Eastern Quays until 1971, a few yards from where the trams will stop. Several people in the audience on Saturday had grandparents who worked there, and the director, Sara Quince, has filled the programme with photographs from the city archive. At the end the cast took four calls, and the applause went on after the house lights came up.

*The Tally Clerk* runs at the Playhouse, Water Lane, until 31 October, at half past seven, with matinées on Wednesdays and Saturdays at half past two. Tickets cost £14 to £32. The performance on Thursday 22 October will be captioned, and the one on Saturday 24 October relaxed. Book early for the last week: the Friday and Saturday evenings are already close to full, and the theatre has no plans to extend the run.

:::columnbreak

:::paragraphs{style="label"}
What’s on
:::

:::paragraphs{style="brief"}
**Film.** *The Long Tide*, a documentary about the last oyster fishermen of the Arle, at the Regal on Friday at 8, followed by a talk with its director. £9.

**Music.** The Hollin Point Folk Club meets in the back room of the Anchor every Tuesday from half past eight. Bring an instrument or a song. Free.

**Talks.** The city archivist, Jane Merriweather, on the clerks of the Steam Packet Company, at the Central Library on Wednesday 21 October at 6. Free, booking needed.

**Exhibitions.** Photographs of the docks from 1880 to 1970, from the city archive, at the Corn Hall gallery until 15 November. Open daily from ten. Free.

**Children.** Puppet stories at Westfield library every Saturday at eleven, for children under six and the grown-ups who bring them. Free.

**Comedy.** The Late Shift, four new comedians from the city, at the Playhouse studio on Friday 23 October at nine. £10.

**Walks.** A guided walk along the route of the Harbour Line, from Northgate station to the Eastern Quays, with the Corrington Civic Society. Sunday at two, from the station clock. £4.

**Books.** The novelist Clara Venn reads from *Salt Roads*, her new book about the herring girls of the east coast, at Bridge Street Books on Thursday at seven. £5, refunded against the book.

**Markets.** The farmers’ market fills Castle Square on the first and third Saturday of the month, from eight until one, with forty stalls from the valley. Free.

**Sport.** Corrington Harbour play Elsby Town at the Quay Ground on Saturday at three. Adults £12, children £4.

**Dance.** Tea dance at the Guildhall every Thursday from two until five, with a live trio and sandwiches. £6 at the door.
:::

:::paragraphs{style="imprint"}
*The Corrington Despatch* is published by Despatch Newspapers, 12 Bridge Street, Corrington. An imaginary newspaper: its people, places and figures are invented. Text CC BY 4.0. Set in Newsreader, Playfair Display and Archivo Narrow (SIL OFL). Photographs generated with diffusion models.
:::
`; // content.back.en.md: page 4
const markdown = [front, city, views, back].join('\n\n');

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  Newsreader: ['400', '400i', '600', '700', '700i'], 'Playfair Display': ['700', '800', '900'],
  'Archivo Narrow': ['400', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all([loadImage('quays-1440.jpg', asset('quays-1440.jpg')),
  loadImage('barrier-1152.jpg', asset('barrier-1152.jpg')),
  loadImage('terrace-1440.jpg', asset('terrace-1440.jpg')),
  loadImage('baths-1536x768.jpg', asset('baths-1536x768.jpg')),
  loadImage('stage-1800.jpg', asset('stage-1800.jpg')),
  ...Object.entries(SKY).map(([id, svg]) => loadSvg(`${id}.svg`, svg))]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'The Corrington Despatch' });

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

### Compor um berliner em cinco colunas

O mesmo jornal num berliner de 315 × 470 mm: cinco colunas de 55 mm, a medida das seis do standard. O fio do fólio também fica com a largura menor; depois reajuste cada página.

```diff
-const layout = { layoutType: 'multiple', columnCount: 6, gutterWidth: mm(GUTTER),
+const layout = { layoutType: 'multiple', columnCount: 5, gutterWidth: mm(GUTTER),
-      size: { width: mm(375 - 2 * MARGIN.side) } }] };
+      size: { width: mm(315 - 2 * MARGIN.side) } }] };
-  page: { sizePreset: 'broadsheet', dpi: 150, backgroundColor: col('paper'), // 375 × 597 mm
+  page: { sizePreset: 'berliner', dpi: 150, backgroundColor: col('paper'), // 315 × 470 mm
```

## Erros comuns

- **O fio de coluna pode atravessar uma abertura na largura da página.** No postext 1.4.1, o fio de coluna de uma faixa começa no alto da sua coluna mais alta. Um título na largura da página fica na primeira coluna, então, quando a abertura não termina exatamente numa linha da grade, a fresta de segunda coluna que sobra ao lado faz o fio começar no alto da mancha de texto e atravessar a abertura. Dê à abertura uma altura de um número inteiro de linhas da grade, com uma caixa vazia dessa altura ou com minHeight quando o design for mais baixo: assim ela termina numa linha da grade e não deixa fresta.
- **Bitmaps são diagramados em px na resolução do documento: declare o tamanho de impressão.** Um recurso bitmap é dimensionado pela largura e pela altura que declara, em pixels na resolução (dpi) do documento, não pelo arquivo. Declare os pixels do tamanho de impressão (cerca de 300 dpi na largura impressa) para que a figura saia no tamanho certo e nítida.
- **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.
- **O excesso de texto de design é 'ellipsis-end' por padrão.** Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas.
- **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.
- **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.

- Um flutuante aberto depois que o texto já passou pelas colunas dele não encontra espaço na página e abre a seguinte. Na primeira página, a matéria principal precisa terminar na coluna 4: com um parágrafo a mais, o boxe da manchete e a foto da segunda matéria vão para a página 2.
- Cada página aqui é ajustada à mão, como o editor de fechamento ajusta um jornal: as linhas `:::space` dos anúncios e o tamanho de cada matéria definem onde as colunas terminam. Mudou uma matéria, reajuste a página dela.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imagens: The Eastern Quays at sunrise (the front-page lead photograph): Generated With Diffusion Models, CC-BY-4.0
- Imagens: Engineers watching a river flood gate (front page, second story): Generated With Diffusion Models, CC-BY-4.0
- Imagens: A terraced street on moving day (page 2): Generated With Diffusion Models, CC-BY-4.0
- Imagens: A Victorian swimming baths on a weekday morning (page 3): Generated With Diffusion Models, CC-BY-4.0
- Imagens: A period drama on a theatre stage (page 4): Generated With Diffusion Models, CC-BY-4.0
- Tipos: Newsreader (OFL-1.1), Playfair Display (OFL-1.1), Archivo Narrow (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 027 · Primeira página de jornal](https://postext.dev/pt/cookbook/newspaper-front-page.md): Primeira página e página 2 de um semanário local em duas colunas com fio, manchetes na largura da página em boxes e uma faixa de notas em quatro colunas no pé. · Nível 3 (Avançado) · Jornais e boletins
- [Nº 108 · Uma página de jornal árabe em duas colunas](https://postext.dev/pt/cookbook/arabic-newspaper-two-columns.md): Um diário árabe com locale 'ar': a primeira coluna é a da direita, as manchetes ocupam as duas e as notas breves vão em três colunas da direita para a esquerda. · Nível 2 (Intermediário) · Jornais e boletins
- [Nº 021 · Boxes que se dividem, flutuam e ficam fixos](https://postext.dev/pt/cookbook/boxes-split-float-pin.md): Uma ficha de laboratório em que o boxe do procedimento se divide em duas colunas, a folha de dados vai para o alto da página seguinte e um selo fica fixo no pé. · Nível 3 (Avançado) · Cadernos de exercícios, Livros didáticos
