# Un diari llençol a sis columnes

> Quatre pàgines d'un diari llençol: sis columnes amb filet, fotos i requadres sobre dues a cinc d'elles, anuncis a tota l'amplada i opinió a cinc.

- Versió HTML: https://postext.dev/ca/cookbook/broadsheet-six-columns
- Recepta Núm. 133 · Pàgina i retícula · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Diaris i butlletins
- Requereix postext ≥ 1.18.0 · provada amb 1.18.0 el 2026-10-06
- Pàgines: [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)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=broadsheet-six-columns&lang=en (.postext: https://postext.dev/cookbook/broadsheet-six-columns/en/broadsheet-six-columns.postext)
- Última actualització: 2026-10-06
- Altres idiomes: [en](https://postext.dev/en/cookbook/broadsheet-six-columns.md), [es](https://postext.dev/es/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)

## En poques paraules

Quatre pàgines d'un diari gran, compostes en sis columnes estretes. Ensenya a posar una foto o un requadre sobre diverses columnes i a donar cinc columnes a una pàgina.

## Què compondràs

Quatre pàgines de *The Corrington Despatch*, un diari inventat, en format llençol de 375 × 597 mm. El text corre en sis columnes de 55,2 mm amb mitgeres de 4 mm i un filet fi a cadascuna: uns 38 caràcters per línia en cos 9, la mesura d'un diari llençol de debò. La portada porta la capçalera amb dues orelles, una franja de crides, una notícia principal amb el titular i la foto a quatre columnes, una segona notícia amb foto a dues columnes, una columna de breus, una taula de finançament i un requadre de dades a dues columnes, i un anunci al peu. La pàgina 2 obre amb la continuació de la portada, la 3 passa a cinc columnes per als editorials, una tribuna i les cartes, i la contraportada porta una crítica de teatre i el temps.

**Aquesta recepta respon a:**

- Com componc un diari llençol a sis columnes, amb fotos i requadres que n'ocupin dues, tres o quatre?
- Pot Postext compondre tres o més columnes de text, o fer que el text envolti una imatge?

## La resposta curta

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

## Ingredients

**Ensenya**

- [Tres o més columnes](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició): layoutType 'multiple' divideix el text en columnCount columnes iguals, de 3 a 8, per a diaris i revistes; els filets, l'equilibri, les notes al peu i els flotants a tota l'amplada funcionen en totes.
- [Flotants a diverses columnes](https://postext.dev/ca/docs/document-format.md#collocació): placement.columns col·loca una figura o una taula sobre k columnes contigües, i el columns d'un estil de requadre (o l'atribut columns de la tanca) fa el mateix amb un requadre flotant: una foto sobre dues de les cinc columnes d'un diari.

**També fa servir**

- [Formats de diari](https://postext.dev/ca/docs/configuration.md#mides-de-pàgina-predefinides)
- [Diari plegat (Folio)](https://postext.dev/ca/docs/configuration.md#visor-folio-configuració)
- [Filet de columna](https://postext.dev/ca/docs/configuration.md#filet-de-columna)
- [Requadres a tota l'amplada](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Requadres flotants](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Geometria per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Estils de taula amb nom](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom)
- [Imatges a les cel·les](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Requadres que es parteixen o no](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Salts de pàgina i de columna](https://postext.dev/ca/docs/document-format.md#pagebreak)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

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

## Elaboració

### 1 · Sis columnes en format llençol

El codi d'aquest pas és [la resposta curta](#la-resposta-curta) de dalt. `page.sizePreset: 'broadsheet'` fixa el format de 375 × 597 mm, i `layoutType: 'multiple'` amb `columnCount: 6` talla els 351 mm de caixa en sis columnes iguals ([Tipus de disposició](/ca/docs/configuration#tipus-de-disposició)). Cadascuna fa (351 − 5 × 4) / 6 = 55,2 mm, i en Newsreader de 9 pt hi caben uns 38 caràcters, així que el text necessita partició de mots, `minWordSpacing: 0.8` perquè els espais no es tanquin i `maxJustifyTracking: 15` per repartir una línia fluixa entre les seves lletres. El filet de columna traça una línia de 0,4 pt a cada mitgera i es talla on el creua un flotant o un requadre. Fins a postext 1.17 el cos tenia dues columnes com a molt, i per això la [Nº 027](https://postext.dev/ca/cookbook/newspaper-front-page.md) posava les seves quatre columnes de breus dins d'un requadre.

### 2 · Un flotant a k columnes pren la primera tanda de columnes buides

```js
// script.js, línies 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` dona a una foto o a una taula la seva amplada en columnes, mitgeres incloses: quatre columnes de la portada són 232,7 mm. El motor busca el buit des de la columna on va el text, d'esquerra a dreta. Un flotant `top` pren el capdamunt de la primera tanda de k columnes que encara no tenen text i comencen a la mateixa altura; un `bottom` pren el peu de la columna del text i de les buides que la segueixen. Per això la taula de finançament, citada en un paràgraf de la columna 3, cau al peu de les columnes 3 i 4, i a la [pàgina 2](https://postext.dev/cookbook/broadsheet-six-columns/en/p02.webp?v=49f78219) el requadre de dades, que s'obre abans, pren les columnes 1 i 2 i deixa les 3 a 6 com a primera tanda a la mateixa altura per a la foto a quatre columnes que ve després. En l'ordre invers, el requadre s'apilaria sota la foto. Una k igual al nombre de columnes de la pàgina, o més gran, ocupa tota l'amplada, com `span: 'page'`.

### 3 · Titulars, requadres de dades i anuncis

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

Un `:::callout` amb `placement="top"` i `columns="4"` flota sobre quatre columnes igual que una foto. El titular i l'entradeta de la notícia principal són un requadre així, obert abans del text, de manera que pren les columnes 1 a 4 i la foto s'apila a sota. El requadre del titular de la segona notícia va després de la principal, darrere d'un `:::columnbreak`: el text ja és a la columna 5, i la 5 i la 6 són la primera parella buida. Cada anunci és un requadre amb `columns="6"`, a tota l'amplada, obert al principi de la seva pàgina perquè el peu quedi reservat abans que arribi el text; els titulars de continuació de les pàgines 2 i 4 són requadres a tota l'amplada, com a la Nº 027. `balancing.trailing: false` deixa que cada pàgina ompli les columnes fins al peu, perquè aquí cada pàgina tanca una secció.

### 4 · La capçalera és l'H1 de la portada

```js
// script.js, línies 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` pren l'estil de títol `front`: el nom en Playfair Display Black de 64 pt, dues orelles que s'omplen amb els atributs del títol i la línia de data entre un filet fi i un altre de gruixut. L'obertura fa nou línies de la reixeta base. A 1.18.0 una obertura ocupa la columna 1, així que un flotant a k columnes obert just darrere comença a la columna 2; la franja tintada de crides, un requadre a tota l'amplada, obre una banda nova amb la primera columna buida, i la notícia principal pren les columnes 1 a 4.

### 5 · La pàgina d'opinió va a cinc columnes

```js
// script.js, línies 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"}` obre una secció l'estil de títol de la qual fixa `layout: { layoutType: 'multiple', columnCount: 5 }`. La secció conserva la mitgera i el filet del document i només canvia el nombre de columnes, així que els editorials, la tribuna i les cartes corren a 67 mm, uns 47 caràcters. El requadre del titular de la tribuna porta `columns="3"` i s'obre quan l'editorial ja ha començat a la columna 1, de manera que cau a les columnes 2 a 4 amb la foto a sota; dues línies `:::columnbreak` envien la tribuna a la columna 2 i les cartes a la 5.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variants

### Compon un berlinès a cinc columnes

El mateix diari en format berlinès de 315 × 470 mm: cinc columnes de 55 mm, la mesura de les sis del format llençol. El filet del foli pren també l'amplada menor; després cal tornar a ajustar 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
```

## Errors freqüents

- **El filet de columna pot travessar una obertura a tota l'amplada.** A postext 1.4.1 el filet de columna d'una banda comença al capdamunt de la seva columna més alta. Un títol a tota l'amplada es queda a la primera columna, així que, si la seva obertura no acaba just en una línia de la retícula de base, l'escletxa de segona columna que queda al costat fa que el filet arrenqui al capdamunt de la caixa de text i travessi l'obertura. Dona a l'obertura una alçada de línies de retícula senceres, amb una caixa buida d'aquesta alçada o amb minHeight si el disseny és més baix: així acaba en una línia de la retícula i no deixa cap escletxa.
- **Els mapes de bits es mesuren en píxels a la resolució del document: declara la mida d'impressió.** Un recurs de mapa de bits pren la mida de l'amplada i l'alçada que declara, en píxels a la resolució del document, no del fitxer. Declara els píxels de la mida d'impressió (uns 300 ppp a l'amplada impresa) perquè la figura surti a la seva mida i nítida.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Un flotant que s'obre quan el text ja ha passat per les seves columnes no troba buit a la pàgina i obre la següent. A la portada la notícia principal ha d'acabar a la columna 4: amb un paràgraf més, el titular i la foto de la segona notícia passen a la pàgina 2.
- Cada pàgina s'ajusta a mà, com ajusta un diari el redactor de tancament: les línies `:::space` dels anuncis i la llargada de cada notícia decideixen on acaben les columnes. Si canvies una notícia, torna a ajustar-ne la pàgina.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: The Eastern Quays at sunrise (the front-page lead photograph): Generated With Diffusion Models, CC-BY-4.0
- Imatges: Engineers watching a river flood gate (front page, second story): Generated With Diffusion Models, CC-BY-4.0
- Imatges: A terraced street on moving day (page 2): Generated With Diffusion Models, CC-BY-4.0
- Imatges: A Victorian swimming baths on a weekday morning (page 3): Generated With Diffusion Models, CC-BY-4.0
- Imatges: A period drama on a theatre stage (page 4): Generated With Diffusion Models, CC-BY-4.0
- Tipus de lletra: Newsreader (OFL-1.1), Playfair Display (OFL-1.1), Archivo Narrow (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 027 · Portada de diari](https://postext.dev/ca/cookbook/newspaper-front-page.md): Portada i pàgina 2 d'un setmanari local a dues columnes amb filet, titulars a tota l'amplada en requadres i una franja de breus a quatre columnes al peu. · Nivell 3 (Avançat) · Diaris i butlletins
- [Núm. 108 · Una pàgina de diari àrab a dues columnes](https://postext.dev/ca/cookbook/arabic-newspaper-two-columns.md): Un diari àrab amb locale 'ar': la primera columna és la dreta, els titulars ocupen les dues i els breus van en tres columnes de dreta a esquerra. · Nivell 2 (Intermedi) · Diaris i butlletins
- [Núm. 021 · Requadres que es parteixen, floten i es fixen](https://postext.dev/ca/cookbook/boxes-split-float-pin.md): Un full de pràctiques en què el procediment es parteix entre dues columnes, el full de dades encapçala la pàgina següent i una insígnia va fixa al peu. · Nivell 3 (Avançat) · Quaderns i exercicis, Llibres de text
