# ブランケット判6段の日刊紙

> ブランケット判の日刊紙4ページ。罫線付きの6段、2〜5段にまたがる写真と囲み、全段の広告、5段に切り替えたオピニオン面。

- HTML版: https://postext.dev/ja/cookbook/broadsheet-six-columns
- レシピ No. 133 · ページとグリッド · 難易度 3 (上級) · 出力: Canvas
- ジャンル: 新聞とニュースレター
- 必要なもの postext ≥ 1.18.0 · テスト環境 1.18.0 ／テスト日 2026-10-06
- ページ: [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)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=broadsheet-six-columns&lang=en (.postext: https://postext.dev/cookbook/broadsheet-six-columns/en/broadsheet-six-columns.postext)
- 最終更新: 2026-10-06
- 他の言語: [en](https://postext.dev/en/cookbook/broadsheet-six-columns.md), [es](https://postext.dev/es/cookbook/broadsheet-six-columns.md), [ca](https://postext.dev/ca/cookbook/broadsheet-six-columns.md), [zh](https://postext.dev/zh/cookbook/broadsheet-six-columns.md), [ar](https://postext.dev/ar/cookbook/broadsheet-six-columns.md)

## かんたんな説明

大判の日刊紙4ページを、細い6段で組む例です。写真や囲みを何段かにまたがらせる方法と、1ページだけ5段にする方法を示します。

## できあがり

架空の日刊紙 *The Corrington Despatch* の4ページを、375 × 597 mmのブランケット判で組みます。本文は幅55.2 mmの6段で、段間4 mmにそれぞれ細い罫が入ります。9 ptで1行およそ38字、実際のブランケット判と同じ行長です。第1面には天気と潮汐の小枠を両脇に置いた題字、案内の帯、見出しと写真が4段にまたがるトップ記事、2段写真付きの2番手記事、短信の段、財源の表と2段のデータ囲み、下端の広告があります。第2面は第1面の続きから始まり、第3面は5段に切り替えて社説、論評、投書を載せ、終面には劇評と天気が入ります。

**このレシピが答える問い:**

- ブランケット判を6段で組み、写真や囲みに2段、3段、4段をとらせるにはどうすればよいですか？
- Postextは3段以上の段組みや、画像の周りへの回り込みに対応していますか？

## 手短な答え

```js
// script.js, 行 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'.
```

## 材料

**学べること**

- [3段以上の段組み](https://postext.dev/ja/docs/configuration.md#レイアウトの種類): layoutType 'multiple'は本文をcolumnCount個の同じ幅の段（3〜8段）に分けます。新聞や雑誌向けで、段間罫、段のそろえ、脚注、ページ幅のフロートはすべての段で働きます。
- [複数段にまたがるフロート](https://postext.dev/ja/docs/document-format.md#配置): placement.columnsは図や表を隣り合うk段にまたがって置きます。囲みスタイルのcolumns（またはフェンスのcolumns属性）はフロートする囲みに同じことをします。新聞の5段のうち2段にまたがる写真などです。

**ほかに使うもの**

- [新聞の判型](https://postext.dev/ja/docs/configuration.md#ページサイズのプリセット)
- [折った新聞（Folio）](https://postext.dev/ja/docs/configuration.md#folioビューアー設定)
- [段間罫](https://postext.dev/ja/docs/configuration.md#段間罫)
- [段抜きの囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [フロートする囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [囲みの中の段組み](https://postext.dev/ja/docs/document-format.md#columns)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [節ごとのページ構成](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [名前付きの表スタイル](https://postext.dev/ja/docs/configuration.md#名前付きの表スタイル)
- [表のセル内の画像](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [分割する囲み、分割しない囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形)
- [その場に置く図](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- [改ページと改段](https://postext.dev/ja/docs/document-format.md#pagebreak)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`tableStyle`](https://postext.dev/ja/docs/configuration.md#表スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

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

## 作り方

### 1 · ブランケット判に6段

この手順のコードは上の[手短な答え](#手短な答え)です。`page.sizePreset: 'broadsheet'`で375 × 597 mmの判型になり、`layoutType: 'multiple'`と`columnCount: 6`で幅351 mmの版面を等幅の6段に分けます（[レイアウトの種類](/ja/docs/configuration#レイアウトの種類)）。1段は(351 − 5 × 4) / 6 = 55.2 mmで、9 ptのNewsreaderなら約38字。そのため本文にはハイフネーションが要り、`minWordSpacing: 0.8`で語間が詰まりすぎないようにし、`maxJustifyTracking: 15`で間延びした行のすき間を字間に配ります。段間罫は各段間に0.4 ptの細線を引き、段をまたぐフロートや囲みのところで途切れます。postext 1.17までは本文は2段が上限で、そのため[No. 027](https://postext.dev/ja/cookbook/newspaper-front-page.md)は4段の短信を囲みの中に入れていました。

### 2 · k段にまたがるフロートは最初の空き段の並びに入る

```js
// script.js, 行 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`は写真や表の幅を、段間を含めた段数で指定します。第1面の4段は232.7 mmです。エンジンは本文のある段から左へ右へと場所を探します。`top`のフロートは、まだ本文がなく天がそろったk段の並びのうち最初のものの頭に入り、`bottom`のフロートは本文のある段とその後の空き段の足に入ります。そのため3段目の段落で参照した財源の表は3・4段目の下に置かれます。[第2面](https://postext.dev/cookbook/broadsheet-six-columns/en/p02.webp?v=49f78219)では、先に書いたデータ囲みが1・2段目をとるので、3〜6段目が次の4段写真のための最初のそろった並びになります。順番を逆にすると、囲みは写真の下に積まれます。kがページの段数以上なら全段で、`span: 'page'`と同じです。

### 3 · 見出しの囲み、データの囲み、広告

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

`placement="top"`と`columns="4"`を付けた`:::callout`は、写真と同じように4段にまたがって浮きます。トップ記事の見出しとリードはこの囲みで、本文の前に書くので1〜4段目をとり、写真はその下に積まれます。2番手記事の見出しの囲みはトップ記事の後、`:::columnbreak`の後ろに置きます。このとき本文はもう5段目にあり、5・6段目が最初の空いた2段になります。広告はどれも`columns="6"`の全段の囲みで、本文が来る前に下端を確保するよう、そのページの冒頭に書きます。第2面と第4面の続き見出しはNo. 027と同じく全幅の囲みです。ここではどのページもひとつのセクションを閉じるので、`balancing.trailing: false`で各段を下端まで埋めます。

### 4 · 題字は第1面のH1

```js
// script.js, 行 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`は見出しスタイル`front`をとります。紙名は64 ptのPlayfair Display Black、両脇の小枠は見出しの属性から埋まり、日付行は細い罫と太い罫に挟まれます。オープナーの深さは行送り9行分です。1.18.0ではオープナーが1段目を占めるため、その直後に書いたk段のフロートは2段目から始まります。地色の案内帯は全幅の囲みで、1段目が空いた新しい帯を開くので、トップ記事は1〜4段目をとれます。

### 5 · オピニオン面は5段

```js
// script.js, 行 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"}`は、見出しスタイルで`layout: { layoutType: 'multiple', columnCount: 5 }`を指定したセクションを開きます。段間と段間罫は文書のものを引き継ぎ、段数だけを変えるので、社説、論評、投書は67 mm、約47字の行長になります。論評の見出しの囲みは`columns="3"`で、社説が1段目で始まってから書くので2〜4段目に入り、写真はその下に来ます。2行の`:::columnbreak`が論評を2段目へ、投書を5段目へ送ります。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: 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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### ベルリナー判を5段で組む

同じ新聞を315 × 470 mmのベルリナー判にすると、5段で1段55 mm、ブランケット判6段と同じ行長です。柱の罫も狭い幅に合わせ、各ページを割り付け直します。

```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
```

## よくあるつまずき

- **段間罫がページ幅の章扉を貫いて走ることがある.** postext 1.4.1では、段組みの区画の段間罫は、最も高い段の頭から始まります。ページ幅の見出しは1段目にとどまるため、その章扉がグリッドの行ちょうどで終わらないと、横に残った2段目のわずかな隙間のせいで罫が版面の頭から始まり、章扉を横切ります。章扉の深さをグリッドの整数行にしてください。デザインが短いときは、その高さの空の箱かminHeightを使います。そうすれば章扉はグリッドの行で終わり、隙間が残りません。
- **ビットマップは文書のdpiでのpxで配置される。印刷サイズを宣言する.** ビットマップのリソースの大きさは、ファイルからではなく、宣言した幅と高さ（文書のdpiでのピクセル数）から決まります。印刷サイズのピクセル数（刷り上がりの幅で約300 dpi）を宣言すれば、図は正しい大きさに収まり、鮮明さも保たれます。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 本文がすでに通り過ぎた段に向けて書いたフロートは、そのページに場所がなく、次のページに回ります。第1面のトップ記事は4段目で終わらなければならず、1段落増えるだけで2番手記事の見出しと写真が第2面へ移ります。
- どのページも、整理記者が紙面を割り付けるように手で合わせます。広告の`:::space`の行数と各記事の長さが、段の終わる位置を決めます。記事を変えたら、そのページを割り付け直してください。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: The Eastern Quays at sunrise (the front-page lead photograph): Generated With Diffusion Models, CC-BY-4.0
- 画像: Engineers watching a river flood gate (front page, second story): Generated With Diffusion Models, CC-BY-4.0
- 画像: A terraced street on moving day (page 2): Generated With Diffusion Models, CC-BY-4.0
- 画像: A Victorian swimming baths on a weekday morning (page 3): Generated With Diffusion Models, CC-BY-4.0
- 画像: A period drama on a theatre stage (page 4): Generated With Diffusion Models, CC-BY-4.0
- 書体: Newsreader (OFL-1.1), Playfair Display (OFL-1.1), Archivo Narrow (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 027 · 新聞の第1面](https://postext.dev/ja/cookbook/newspaper-front-page.md): 地方週刊紙の第1面と2ページ目を罫で区切った2段で組み、ページ幅の大見出しは囲みとして組み、4段の短信欄は下端にフロートさせます。 · 難易度 3 (上級) · 新聞とニュースレター
- [No. 108 · 2段組みのアラビア語新聞](https://postext.dev/ja/cookbook/arabic-newspaper-two-columns.md): ロケール'ar'のアラビア語日刊紙です。第1段は右の段で、見出しは両段にまたがり、「短信」欄は3段を右から左へ組みます。 · 難易度 2 (中級) · 新聞とニュースレター
- [No. 021 · 分かれる囲み、フロートする囲み、固定する囲み](https://postext.dev/ja/cookbook/boxes-split-float-pin.md): 手順の囲みが2つの段に分かれ、データシートが次のページの頭へ移り、バッジが地に固定される理科の実習ワークシート。 · 難易度 3 (上級) · ワークブックと練習問題, 教科書
