# 新聞の第1面

> 地方週刊紙の第1面と2ページ目を罫で区切った2段で組み、ページ幅の大見出しは囲みとして組み、4段の短信欄は下端にフロートさせます。

- HTML版: https://postext.dev/ja/cookbook/newspaper-front-page
- レシピ No. 027 · ページとグリッド · 難易度 3 (上級) · 出力: Canvas
- ジャンル: 新聞とニュースレター
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/newspaper-front-page/en/p01.webp?v=12406580), [2](https://postext.dev/cookbook/newspaper-front-page/en/p02.webp?v=12406580)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=newspaper-front-page&lang=en (.postext: https://postext.dev/cookbook/newspaper-front-page/en/newspaper-front-page.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/newspaper-front-page.md), [es](https://postext.dev/es/cookbook/newspaper-front-page.md), [ca](https://postext.dev/ca/cookbook/newspaper-front-page.md), [zh](https://postext.dev/zh/cookbook/newspaper-front-page.md), [ar](https://postext.dev/ar/cookbook/newspaper-front-page.md)

## かんたんな説明

地方の週刊新聞の第1面と2ページ目。大きな見出しをページいっぱいに渡し、短いニュースを並べた帯をページの下に置く方法を示します。

## できあがり

架空の谷あいの週刊紙『The Elverdale Courier』の第1面と2ページ目を、246 × 328 mmの紙に組みます。ブラックレターの題字が天気予報の耳と価格の耳の間に座り、その下に太い罫と細い罫、日付行が続きます。トップ記事は、Libre Franklin Blackの56 ptで両段にまたがる2行の大見出し、イタリックの副見出し、幅222 mmの写真の帯で始まり、細い罫で区切られた両端そろえの2段で続きます。下端では4本の短信が色を敷いた帯を1段ずつ分け合い、それぞれ赤いチップで始まります。2ページ目は赤い欄名で始まり、コードで描いた図を添えた橋の記事、右の段の頭に置いた5日間の天気予報、ページを横切る2つ目の見出し、投書欄、各行に棒の付いた区ごとの投票結果の表を収めます。

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

- 新聞のページを3段以上で組んだり、写真の周りに本文を回り込ませたりできますか？
- ページの途中に、2段をまたぐ囲み（3つ並びの「数字で見る」欄など）を置くには？
- 図の位置を制御するには（ページの上端、2段をまたぐ、まさにこの位置、余白）？
- 章タイトルをページ幅いっぱいに置き、その下で本文を2段組みにするには？

## 手短な答え

```js
// script.js, 行 83–100
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the most a body can have
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } };
// Three or more columns live only inside a box: a :::columns group in a :::callout. Its fence
// spans the page and floats the box to the foot, so the story fills the columns above it:
//   :::callout{type="briefs" span="page" placement="bottom" title="In brief"}
//   :::columns{count=4 breaks="2,3,4"}   ← each brief opens a column (child 2, 3 and 4)
//   …four paragraphs…
//   :::
//   :::                                  (gotcha: callout-columns)
// The style could carry span and placement too; on the fence they stay in sight in the text.
// hook-up: config() takes layout as it is and lists briefs in calloutStyles.
const briefs = { id: 'briefs',
  background: col('tint'), // one device: a tint, no stripe or border
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  columnGap: mm(GUTTER), // the inner columns keep the page's gutter
  titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) },
  body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns
```

## 材料

**学べること**

- [段抜きの囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー): ページの途中ですべての段をまたぐ囲みです。その上の段は高さをそろえて終わり、囲みの下で再開します。
- [囲みの中の段組み](https://postext.dev/ja/docs/document-format.md#columns): 囲みの中に高さをそろえた2つ以上の段を組みます。図の横にテキストの段を置く形や、3つ並べたパネルなどに使います。
- [フロートする囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー): 流れを離れてページの上端か下端に移る囲みです。本文は元のページを埋め続けます。

**ほかに使うもの**

- [デザインした章扉](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#柱とノンブル)
- [柱とノンブル](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/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/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- citations
- [用紙の色](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#キャプションスタイル), [`chipStyles`](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#表スタイル), [`tableStyles`](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#ページをビットマップに描画する)

**書体**

- Grenze Gotisch (OFL-1.1), PT Serif (OFL-1.1), Libre Franklin (OFL-1.1)

## 作り方

### 1 · 新聞用紙、インク、赤1色

```js
// script.js, 行 14–27
const palette = {
  ink: '#111315', // text and the heavy rules: a cold near-black
  paper: '#f8f5ee', // newsprint, and type reversed out of ink or red
  flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars
  tint: '#ebe6db', // the In brief strip
  rule: '#9a978f', // hairlines: the column rule, table rules
  muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint
};
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
  ...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' } },
];
```

ページは新聞用紙の色（#f8f5ee）、文字は黒に近いインク（#111315）です。ただ1つの赤、#a6192eは、欄名、見出し上のラベル、チップ、賛成の棒に使います。1.4.1はデザイン要素を16進値から塗るので、各色は`paletteId`と16進値の両方を持ちます。`main-color`はインクを指すので、イタリックや、設定で指定しなかった既定値はすべてインクで印刷されます。`bodyText.boldColor`もインクに設定します。参照の色はこの値にフォールバックし、各囲みは太字にこの値を写しますが、パレットはどちらも塗り替えません。この設定がないと、2ページ目の2つの参照と短信の太字の書き出しが既定の青、#295AA3で印刷されます。

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

```js
// script.js, 行 51–79
const NAME = 52; // pt: the blackletter nameplate
const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side
const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline
const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } }));
const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'),
  lineHeight: 1.25 }; // a multiple of the size (gotcha: design-lineheight-multiple)
const nameplate = { enabled: true, slot: { elements: [
  depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align },
      { ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule
  text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME),
    fontWeight: 700, lineHeight: 1, align: 'center' },
  { ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears
  ...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')],
    ['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]),
  ...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')],
    ['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]),
] } };
// hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements:
// [] } }, H1 already spans the page, and the Markdown opens with
// # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…"
//   day="…" price="…"}
```

`# The Elverdale Courier`は`front`見出しスタイルを使い、見出しのテキストと7つの属性（`issue`、`area`、`outlook`、`temps`、`since`、`day`、`price`）から題字部分を埋めます。日付はフロントマターの`publishDate`から取ります。グリッド7行分（31.1 mm）の高さを持つ空のボックスが、冒頭部分の高さを決めます。罫、日付行、題字はその下端からぶら下がるので、冒頭部分はグリッド線の上で終わります。ボックスを6.6行分にすると、1.4.1は段間の罫を題字と日付行の中まで引いてしまいます。

### 3 · 両段にまたがる見出しは囲みにする

```js
// script.js, 行 104–117
// :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2
const banner = { id: 'banner', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title
  marginTop: pt(0), // flush under the nameplate or the section flag
  marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line
  body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } };
// A story that starts mid-page: the same box under a rule. The columns above it are cut level.
const story = { ...banner, id: 'story', marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } };
// Inside the box the headline is an H2 in a heading style: one size for each rank of story.
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02),
  marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below
```

見出しが両段にまたがるのは冒頭部分（章扉）としてだけで、章扉は新しいページを始めます。区の記事で、囲みではなくH2に`span: 'page'`を付けると、その記事は3ページ目に移ります。`span="page"`を指定した枠なしの囲みなら、ページの途中から始められます。[2ページ目](https://postext.dev/cookbook/newspaper-front-page/en/p02.webp?v=12406580)では、段はその上で高さをそろえて切られ、下で再開します。囲みには3つの見出しスタイル（56、30、22 pt）のいずれかで組んだH2、イタリックの副見出し、第1面ではさらに写真が入ります。`marginBottom`を半行分にしているので、段は次のグリッド線から始まり、2ページ目では副見出しの3.4 mm下になります。既定のマージンでは空きが7.8 mmになり、投書欄が3ページ目にはみ出します。

### 4 · 3段以上は囲みの中に置く

この手順のコードは上の[手短な答え](#手短な答え)にあります。ページの本文は最大2段です（[レイアウトの種類](/ja/docs/configuration#レイアウトの種類)）。そのため新聞の4段や5段は囲みに入れます。ここではページ幅の囲みに[`:::columns`](/ja/docs/document-format#columns)のグループを入れています。`breaks`はグループのブロックを数えます。短信1本が1ブロックなので、短信2、3、4がそれぞれ2、3、4段目を始めます。各段は幅49 mmで、間にはページと同じ6 mmの段間を取ります。

```markdown
:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
:::columns{count=4 breaks="2,3,4"}
:chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus …

:chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane …
…
:::
:::
```

`placement="bottom"`はこの囲みを[1ページ目](https://postext.dev/cookbook/newspaper-front-page/en/p01.webp?v=12406580)の下端にフロートさせ、囲みは36.9 mmを占めます。記事はその上の83 mmの段を埋めます。また1.4.1は、図版の周りに本文を回り込ませることもできません。フロートは段またはページの帯をまるごと占めるので、トップ記事の写真は大見出しの囲みの中で両段にまたがる帯にし、橋の図は1段目の全幅を使います。図版の横に本文を置くには、同じ種類の囲みを使います。`:::columns{count=2 breaks="2"}`の中で、`::resource`の行が1段目を、本文が2段目を始めます。

### 5 · 図版はインライン、表はフロート

```js
// script.js, 行 342–377
// A caption label prints only when captionPrefix has text: '' leaves no "Figure 1".
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above
const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Set inline in the page-span banner box: a 'top' float never lands above the line that
  // cites it (gotcha: top-float-next-page). A :::space gives it air (gotcha: box-embed-no-gap).
  // 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size).
  resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400,
    height: 800 }, { placement: { position: 'here' },
    altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.',
    caption: '**File picture:** turbines on an upland ridge. Each of the three planned for '
      + 'Harrow Ridge would generate 2.3 megawatts.',
    note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }),
  // Drawn in code (the art region) and set inline, where ::resource puts it in column 1.
  resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 },
    { placement: { position: 'here' },
      altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its '
        + 'central pier, a red concrete footing.',
      caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the '
        + 'riverbed. Both arches were relaid with their own stones.',
      note: 'Drawing: The Courier' }),
  // A page-span 'bottom' float sits under both columns of the page that cites it. On the last
  // page it follows the balanced columns, so the copy there is fitted to bring it to the foot.
  resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() },
    { placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**',
      note: 'Red: yes. Grey: no. The black line marks half the vote. '
        + 'Source: Elverdale Town Council.' }),
  // A column 'top' float waits for the head of the next column with room.
  resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() },
    { placement: { position: 'top', span: 'column' }, caption: '**The next five days**',
      note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }),
];
```

写真を大見出しの囲みの中にインラインで置くのは、ページ幅の`top`フロートが、それを参照する行より上には決して置かれないからです。フロートにすると、写真は2ページ目の頭に来ます（よくあるつまずきを参照）。橋の図も、橋脚についての段落の後にインラインで置きます。天気予報は段の頭にフロートし、右の段の上端に収まります。区の表は両段の下、段間の罫が止まる位置にフロートします。最後のページでは表が段末をそろえた段のすぐ下に付くので、表が下端まで下りるように、上の原稿を調整してあります。どちらのリソースの種類も`captionPrefix: ''`を設定しています。1.4.1はプレフィックスに文字があるときだけキャプションのラベルを印刷するので、「Figure 1」で始まるキャプションはありません。

### 6 · 中面は欄名で始まる

```js
// script.js, 行 121–139
const section = { enabled: true, slot: { elements: [
  depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } },
  text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'),
    padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } },
  at('#bar', 'above')),
] } };
const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 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), size: { width: mm(TRIM.width
      - 2 * MARGIN.side) } } }] };
```

`# Town & Valley`はH1で、その`breakBefore`のパリティは`'any'`です。そのためこの欄は、前に白ページを挟まずに2ページ目を始めます。デザインは3 ptの帯の上の赤い欄名で、グリッド2行分の高さを持つ空のボックスからぶら下げます。ノンブル行はページ上端から6 mm下に固定したヘッダーで、偶数ページ用と奇数ページ用の2種類があります。第1面のスタイルはヘッダーを空にするので、題字の上にノンブル行は印刷されません。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/newspaper-front-page

### script.js

```js
// ═══ Postext Cookbook · Nº 027 · Newspaper front page ═══════════════════════════
// https://postext.dev/en/cookbook/newspaper-front-page
// Code: MIT · Text: original (CC BY 4.0) · Photo: Jason Blackeye (CC0)
// Fonts: Grenze Gotisch, PT Serif, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: newsprint, ink and one red for the section flags
const palette = {
  ink: '#111315', // text and the heavy rules: a cold near-black
  paper: '#f8f5ee', // newsprint, and type reversed out of ink or red
  flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars
  tint: '#ebe6db', // the In brief strip
  rule: '#9a978f', // hairlines: the column rule, table rules
  muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint
};
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
  ...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 TRIM = { width: 246, height: 328 }; // mm: a 3 : 4 compact, smaller than a tabloid
const MARGIN = { top: 14, bottom: 15, side: 12 }; // newspaper margins: narrow, not mirrored
const GUTTER = 6; // mm between the two body columns, and between the In brief columns
const LEAD = 12.6; // body leading in pt: the baseline grid
const franklin = (size, weight, look = {}) => ({ fontFamily: 'Libre Franklin',
  fontSize: pt(size), fontWeight: weight, color: col('ink'), ...look });
const caps = (size, weight, colour = 'ink') => franklin(size, weight, { color: col(colour),
  textTransform: 'uppercase', letterSpacing: pt(size * 0.16) }); // capitals tracked 0.16 em
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 }); // design text defaults to centred,
// black and ellipsized (gotcha: overflow-ellipsis-default)
// A rule across the slot whose bottom sits `gap` mm above the element it names.
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 exactly `lines` grid lines deep sets an opener's depth, and the rest hangs from
// its foot. Ending on a grid line leaves no sliver of column beside the opener, which 1.4.1
// would rule down through the title (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 NAME = 52; // pt: the blackletter nameplate
const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side
const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline
const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } }));
const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'),
  lineHeight: 1.25 }; // a multiple of the size (gotcha: design-lineheight-multiple)
const nameplate = { enabled: true, slot: { elements: [
  depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align },
      { ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule
  text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME),
    fontWeight: 700, lineHeight: 1, align: 'center' },
  { ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears
  ...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')],
    ['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]),
  ...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')],
    ['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]),
] } };
// hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements:
// [] } }, H1 already spans the page, and the Markdown opens with
// # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…"
//   day="…" price="…"}
// #endregion

// #region answer: two body columns at most, so the four-up strip is a box with columns
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the most a body can have
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } };
// Three or more columns live only inside a box: a :::columns group in a :::callout. Its fence
// spans the page and floats the box to the foot, so the story fills the columns above it:
//   :::callout{type="briefs" span="page" placement="bottom" title="In brief"}
//   :::columns{count=4 breaks="2,3,4"}   ← each brief opens a column (child 2, 3 and 4)
//   …four paragraphs…
//   :::
//   :::                                  (gotcha: callout-columns)
// The style could carry span and placement too; on the fence they stay in sight in the text.
// hook-up: config() takes layout as it is and lists briefs in calloutStyles.
const briefs = { id: 'briefs',
  background: col('tint'), // one device: a tint, no stripe or border
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  columnGap: mm(GUTTER), // the inner columns keep the page's gutter
  titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) },
  body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns
// #endregion

// #region banner: a headline across both columns is a frameless page-span box
// :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2
const banner = { id: 'banner', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title
  marginTop: pt(0), // flush under the nameplate or the section flag
  marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line
  body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } };
// A story that starts mid-page: the same box under a rule. The columns above it are cut level.
const story = { ...banner, id: 'story', marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } };
// Inside the box the headline is an H2 in a heading style: one size for each rank of story.
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02),
  marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below
// #endregion

// #region inside: an inside page opens with a section flag and carries a folio line
const section = { enabled: true, slot: { elements: [
  depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } },
  text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'),
    padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } },
  at('#bar', 'above')),
] } };
const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 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), size: { width: mm(TRIM.width
      - 2 * MARGIN.side) } } }] };
// #endregion

const label = { fontFamily: 'Libre Franklin', fontSize: pt(8), firstLineIndent: pt(0) };
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes,
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.side), right: mm(MARGIN.side) } },
  layout,
  // Hyphenation stays at its defaults: on, in 'en-us'.
  bodyText: { fontFamily: 'PT Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    // boldColor is restated: :ref labels take it, because 1.4.1 never points referenceColor
    // at main-color (gotcha: palette-skips-designs), and bold in a box copies it before the
    // palette applies. Italics do follow main-color.
    boldColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.75, maxWordSpacing: 1.6, // tighter than the 0.6–2 defaults
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  headings: { fontFamily: 'Libre Franklin', fontWeight: 800, // in ink, through main-color
    marginBottom: pt(0), levels: [
      // Restated (gotcha: headings-drop-h1-break); 'any': a section opens the next page.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: section },
      { level: 2, fontSize: pt(17), lineHeight: pt(19) }, // Letters; banners restyle it
      { level: 3, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 700, marginTop: pt(LEAD) },
    ] },
  headingStyles: [
    { id: 'front', advancedDesign: nameplate, header: { elements: [] } }, // no folio line
    headline('lead', 56, { fontWeight: 900 }), headline('wide', 30), headline('second', 22),
  ],
  calloutStyles: [banner, story, briefs],
  chipStyles: [{ id: 'flag', background: col('flag'), color: col('paper'), borderWidth: pt(0),
    borderRadius: pt(0), fontFamily: 'Libre Franklin', fontSize: em(0.875), bold: true,
    paddingX: em(0.4), paddingY: em(0.15) }],
  paragraphStyles: [{ ...label, id: 'byline', textAlign: 'left', color: col('muted') },
    { id: 'flush', firstLineIndent: pt(0) }, // a story's first paragraph, the second letter
    { ...label, id: 'jump', textAlign: 'right' }, // "…: page 2", where a story turns
    { id: 'sign', textAlign: 'right', firstLineIndent: pt(0) }, // a letter's signature
    { id: 'imprint', fontFamily: 'Libre Franklin', fontSize: pt(6.8), lineHeight: pt(9),
      textAlign: 'left', firstLineIndent: pt(0), color: col('muted'), marginTop: pt(LEAD) }],
  captionStyle: { fontFamily: 'Libre Franklin', fontSize: pt(8), gap: mm(1.6),
    note: { fontSize: pt(6.8), color: col('muted') } }, // the credit line
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Libre Franklin',
    headerFontSize: pt(7.5), bodyFontFamily: 'Libre Franklin', bodyFontSize: pt(8.5),
    cellPadding: mm(1.3) },
  tableStyles: [{ id: 'results', cellPadding: mm(1) }, // tighter rows: the bars read as one chart
    { id: 'forecast', headerBackground: col('flag'), bodyFontSize: pt(8), // the weather box
      cellPadding: mm(1.2) }],
  header, footer: { elements: [] }, // newspapers put the folio at the head, if anywhere
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region art: the two tables as data, and the bridge and the ward bars drawn in code
const WARDS = [ // Tuesday's parish poll: yes, no (they add up to 3,904 and 2,393)
  ['Market', 942, 418], ['Riverside', 861, 402], ['St Oswald’s', 896, 471],
  ['Brook Lane', 736, 591], ['Harrow', 469, 511]];
const cell = (content, extra = {}) => ({ content, ...extra });
const head = (content, align = 'left') => cell(content, { isHeader: true, align });
const num = (n) => n.toLocaleString('en-GB');
const share = (yes, no) => `${Math.round((100 * yes) / (yes + no))}%`;
function barSvg(yes, no) { // 1000 × 34 units: yes in red from the left, no in grey, 50% marked
  const split = (1000 * yes) / (yes + no);
  return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="34" viewBox="0 0 1000 34">'
    + `<rect width="${split.toFixed(1)}" height="34" fill="${palette.flag}"/>`
    + `<rect x="${(split + 4).toFixed(1)}" width="${(996 - split).toFixed(1)}" height="34" `
    + `fill="${palette.rule}"/><rect x="499" y="-1" width="2" height="36" fill="${palette.ink}"/>`
    + '</svg>';
}
const TOWN = WARDS.reduce(([, y, n], [, yes, no]) => ['Whole town', y + yes, n + no], ['', 0, 0]);
const bars = [...WARDS, TOWN].map(([ward, yes, no], i) => ({ ward, yes, no,
  id: i < WARDS.length ? `bar-${i + 1}` : 'bar-town', svg: barSvg(yes, no) })); // one per row
function mulberry32(seed) { // a seeded PRNG: the same stones on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k)
  + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k
function bridgeSvg() { // 119 × 46 mm, 1 unit = 1 mm, about 2.6 mm to the metre
  const rand = mulberry32(1791); // the year the bridge was built, as far as the Courier knows
  const f = (v) => v.toFixed(2);
  const [W, H, TOP, DECK, WATER, BED] = [119, 46, 4.5, 8, 25, 30]; // heights from the top, mm
  const stone = mix(palette.tint, palette.muted, 0.35);
  const joint = mix(palette.tint, palette.ink, 0.55);
  const arches = [[16, 57], [62, 103]]; // two spans; the central pier stands between them
  const [SPRING, CROWN] = [WATER - 7, 13]; // the arches spring 7 mm above the water
  const CTRL = 2 * CROWN - SPRING; // the Bézier control point that puts the top at CROWN
  // The banks: road level at each end, down under the outer half of each arch to the water's
  // edge, 24 mm in, and on at the same slope to the bed.
  const EDGE = 24;
  const run = ((BED - WATER) * (EDGE - 10)) / (WATER - DECK);
  const bank = [[0, DECK], [10, DECK], [EDGE, WATER], [EDGE + run, BED]];
  const profile = [...bank, ...bank.map(([x, y]) => [W - x, y]).reverse()];
  const groundAt = (x) => { // the height of the ground at x, between two profile points
    const i = profile.findIndex(([px]) => px >= x);
    if (i <= 0) return profile[Math.max(i, 0)][1];
    const [[x0, y0], [x1, y1]] = [profile[i - 1], profile[i]];
    return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
  };
  const water = mix(palette.paper, palette.ink, 0.14);
  let out = `<rect x="0" y="${WATER}" width="${W}" height="${BED - WATER}" fill="${water}"/>`;
  out += `<path d="M${EDGE} ${WATER}H${W - EDGE}" stroke="${palette.muted}" stroke-width="0.3"/>`;
  for (let i = 0; i < 9; i++) { // ripples on the river, between the banks
    const [x, y] = [EDGE + 3 + rand() * (W - 2 * EDGE - 12),
      WATER + 1.2 + rand() * (BED - WATER - 2.4)];
    out += `<path d="M${f(x)} ${f(y)}h${f(3 + rand() * 5)}" stroke="${palette.paper}" `
      + 'stroke-width="0.4"/>';
  }
  // The masonry: parapet to riverbed, less the two arch openings (even-odd fill). The banks
  // are drawn over it, so only the stone above the ground shows.
  const opening = ([a, b]) => `M${a} ${BED}L${a} ${SPRING}Q${(a + b) / 2} ${CTRL} `
    + `${b} ${SPRING}L${b} ${BED}Z`;
  const outline = `M4 ${BED}L10 ${DECK}V${TOP}H${W - 10}V${DECK}L${W - 4} ${BED}`;
  out += `<path fill-rule="evenodd" fill="${stone}" `
    + `d="${outline}Z${arches.map(opening).join('')}"/>`;
  // Where the masonry is at height y: inside the sloping ends, outside the arch openings.
  const solid = (x, y) => {
    const end = y < DECK ? 10 : 10 - (6 * (y - DECK)) / (BED - DECK);
    if (x < end || x > W - end) return false;
    return !arches.some(([a, b]) => { // the opening's half width at y (a parabola)
      const c = (SPRING - y) / (2 * (SPRING - CTRL)); // t(1 − t) at height y
      const half = c <= 0 ? 0.5 : c >= 0.25 ? -1 : Math.sqrt(0.25 - c);
      return Math.abs(x - (a + b) / 2) <= half * (b - a);
    });
  };
  for (let y = TOP + 2.2; y < BED; y += 2.2) { // courses of stone, joints staggered
    for (let x = 4; x < W - 4; x += 0.5) {
      if (solid(x, y) && solid(x + 0.5, y)) {
        out += `<path d="M${f(x)} ${f(y)}h0.5" stroke="${joint}" stroke-width="0.2"/>`;
      }
    }
    for (let x = 4 + rand() * 4; x < W - 6; x += 3 + rand() * 4) {
      if (solid(x, y) && solid(x, y - 2.2)) {
        out += `<path d="M${f(x)} ${f(y - 2.2)}v2.2" stroke="${joint}" stroke-width="0.2"/>`;
      }
    }
  }
  for (const [a, b] of arches) { // the voussoirs: a ring of stones round each opening
    const cx = (a + b) / 2;
    for (let t = 0; t <= 1.0001; t += 1 / 14) {
      const x = a + (b - a) * t;
      const y = SPRING - 2 * t * (1 - t) * (SPRING - CTRL);
      const [dx, dy] = [x - cx, y - (WATER + 2)];
      const k = 3 / Math.hypot(dx, dy);
      out += `<path d="M${f(x)} ${f(y)}l${f(dx * k)} ${f(dy * k)}" stroke="${joint}" `
        + 'stroke-width="0.25"/>';
    }
    out += `<path d="${opening([a, b]).replace(/Z$/, '')}" fill="none" stroke="${palette.ink}" `
      + 'stroke-width="0.35"/>';
  }
  out += `<path d="${outline}" fill="none" stroke="${palette.ink}" stroke-width="0.4"/>`;
  // The ground in section: the banks and the riverbed, hatched below the profile.
  const edge = profile.map(([x, y]) => `${f(x)} ${f(y)}`).join('L');
  out += `<path d="M${edge}L${W} ${H}L0 ${H}Z" fill="${palette.paper}"/>`;
  for (let x0 = -H; x0 < W; x0 += 1.6) { // 45° hatching, cut where it leaves the ground
    let from = null;
    for (let t = 0; t <= H + 0.05; t += 0.1) {
      const [x, y] = [x0 + t, H - t];
      const inside = x >= 0 && x <= W && y > groundAt(x);
      if (inside && !from) from = [x, y];
      if (from && (!inside || t + 0.1 > H + 0.05)) {
        out += `<path d="M${f(from[0])} ${f(from[1])}L${f(x - 0.1)} ${f(y + 0.1)}" `
          + `stroke="${palette.rule}" stroke-width="0.25"/>`;
        from = null;
      }
    }
  }
  out += `<path d="M${edge}" fill="none" stroke="${palette.muted}" stroke-width="0.3"/>`
    + `<rect x="55.5" y="${BED}" width="8" height="15.6" fill="${palette.flag}"/>` // 6 m deep
    + `<path d="M9.4 ${TOP}H${W - 9.4}" stroke="${palette.ink}" stroke-width="1.2"/>`; // coping
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${out}</svg>`;
}
function resultsTable() {
  const row = (name, yes, no, bar, bold = '') => [cell(`${bold}${name}${bold}`),
    cell('', { image: { resourceId: bar } }), cell(`${bold}${num(yes)}${bold}`,
      { align: 'right' }), cell(`${bold}${num(no)}${bold}`, { align: 'right' }),
    cell(`${bold}${share(yes, no)}${bold}`, { align: 'right' })];
  return { headerRowCount: 1, columnWidths: [22, 110, 14, 14, 14], rows: [
    [head('Ward'), head('Share of the vote'), head('Yes', 'right'),
      head('No', 'right'), head('Yes share', 'right')],
    ...bars.map((b) => row(b.ward, b.yes, b.no, b.id, b.id === 'bar-town' ? '**' : ''))] };
}
function forecastTable() {
  const days = [['Thu', 'Bright spells', 16, 8, 'W 18'], ['Fri', 'Showers', 14, 9, 'W 22'],
    ['Sat', 'Heavy rain', 12, 9, 'SW 30'], ['Sun', 'Clearing', 13, 6, 'NW 20'],
    ['Mon', 'Sunny, cold start', 15, 4, 'N 8']];
  return { headerRowCount: 1, columnWidths: [10, 40, 12, 12, 16], rows: [
    [head('Day'), head('Outlook'), head('High', 'right'), head('Low', 'right'),
      head('Wind mph', 'right')],
    ...days.map(([day, sky, hi, lo, wind]) => [cell(`**${day}**`), cell(sky),
      cell(`${hi}°`, { align: 'right' }), cell(`${lo}°`, { align: 'right' }),
      cell(wind, { align: 'right' })])] };
}
// #endregion

// #region floats: the photo sits in its box, the results float to a foot, the forecast to a head
// A caption label prints only when captionPrefix has text: '' leaves no "Figure 1".
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above
const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Set inline in the page-span banner box: a 'top' float never lands above the line that
  // cites it (gotcha: top-float-next-page). A :::space gives it air (gotcha: box-embed-no-gap).
  // 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size).
  resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400,
    height: 800 }, { placement: { position: 'here' },
    altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.',
    caption: '**File picture:** turbines on an upland ridge. Each of the three planned for '
      + 'Harrow Ridge would generate 2.3 megawatts.',
    note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }),
  // Drawn in code (the art region) and set inline, where ::resource puts it in column 1.
  resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 },
    { placement: { position: 'here' },
      altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its '
        + 'central pier, a red concrete footing.',
      caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the '
        + 'riverbed. Both arches were relaid with their own stones.',
      note: 'Drawing: The Courier' }),
  // A page-span 'bottom' float sits under both columns of the page that cites it. On the last
  // page it follows the balanced columns, so the copy there is fitted to bring it to the foot.
  resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() },
    { placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**',
      note: 'Red: yes. Grey: no. The black line marks half the vote. '
        + 'Source: Elverdale Town Council.' }),
  // A column 'top' float waits for the head of the next column with room.
  resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() },
    { placement: { position: 'top', span: 'column' }, caption: '**The next five days**',
      note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }),
];
// #endregion
resources.push(...bars.map((b) => resource(b.id, 'panel', 'svg', // the bars the table cells draw
  { fileId: `${b.id}.svg`, width: 1000, height: 34 },
  { altText: `${b.ward}: ${num(b.yes)} yes, ${num(b.no)} no` })));
const markdown = String.raw`---
title: "The Elverdale Courier"
author: "The Elverdale Courier"
publishDate: "Thursday 24 September 2026"
---

# The Elverdale Courier {style="front" issue="Vol. 116 · No. 39" area="Elverdale, Harrow and the Upper Wend" outlook="Bright spells, breezy" temps="16° · 8°" since="Est. 1911" day="Every Thursday" price="90p"}

:::callout{type="banner" span="page"}
## Harrow Ridge turbines win the town’s vote {style="lead"}

*Elverdale backs its own wind farm by 62% on a 71% turnout. Now the cooperative must raise £9.8 million*

:::space{lines=0.5}

::resource{id="ridge"}
:::

:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
:::columns{count=4 breaks="2,3,4"}
:chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus will run every two hours on Sundays from 4 October, paid for by the county for a year. The first leaves the Market Place at 8.10.

:chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane Primary planted 24 apple and pear trees on Friday, all of them varieties grown in the valley before 1900.

:chip[ARTS]{style="flag"} **Book Weekend full.** Every ticket for the fourteen events of the Book Weekend, 9 to 11 October, has gone. The Saturday talks will be screened free in the Corn Exchange.

:chip[COUNCIL]{style="flag"} **Bin day moves.** Recycling north of the river goes out on Tuesdays instead of Mondays, starting in the week of 5 October. New calendars come with next week’s Courier.
:::
:::

:::paragraphs{style="byline"}
By **Martha Quayle** · Local Democracy Reporter
:::

:::paragraphs{style="flush"}
Elverdale will build a wind farm of its own. In the first town-wide poll since the 1974 vote to save the Corn Exchange, 3,904 residents backed plans for three turbines on Harrow Ridge, and 2,393 voted against.
:::

The count at the Corn Exchange finished shortly before midnight on Tuesday. Turnout was 71%, the highest the town clerk, Anjali Rees, can remember at any election. “People queued in the rain outside St Oswald’s,” she said. “We ran out of pencils at eleven.” The last ballot box, from Harrow, came in at twenty to ten.

The scheme belongs to Wend Valley Energy, a cooperative that residents formed in 2023 so that the wind on the ridge would pay the valley rather than a distant landlord. Its three turbines of 2.3 megawatts each would supply about 6,500 homes in an average year, far more than the 4,700 in the parish.

### Who pays and who gains

The cooperative must now raise £9.8 million. A share offer opens next Thursday, with a minimum stake of £50. It hopes to find £2.4 million in the valley and to borrow the rest from a community energy lender. Members would earn up to 4% a year, and £120,000 a year would go to a town fund, spent by a panel that residents elect.

“Two thousand households putting in £1,200 each would do it,” said Tom Ashby, who chairs the cooperative and farms at Low Harrow. “We had 312 pledges by Wednesday lunchtime.”

### Objectors turn to the planners

The Harrow Ridge Action Group, which campaigned against the scheme, says the turbines, 125 metres to the tip of the blade, will dominate the view from the Wend Way and disturb the curlews that nest on the moor.

“A poll is not a planning permission,” said its secretary, Clare Whitlock. “We will make our case to the district council, and we expect to be heard.”

The planning application is due in the spring, after a year of bird and wind surveys. If it succeeds, work could start in 2028, and the turbines would be turning by the autumn of 2029. The cooperative’s 640 founding members have paid £180,000 for the surveys.

:::paragraphs{style="jump"}
How each ward voted, and what happens next: **page 2**
:::

# Town & Valley

:::callout{type="banner" span="page" title="Transport"}
## Old Mill Bridge reopens after 14 months {style="wide"}

*Cars, buses and the Saturday market cross the Wend again. Lorries must wait for a load test next spring*
:::

:::paragraphs{style="byline"}
By **Owen Pryce**
:::

:::paragraphs{style="flush"}
The Old Mill Bridge reopened to traffic at seven o’clock on Monday morning, fourteen months after the flood of July 2025 scoured out the footing of its central pier and cracked both of its arches.
:::

The repair cost £1.9 million, most of it from the county council’s flood recovery fund. Engineers rebuilt the pier on a new concrete footing sunk six metres into the riverbed, and relaid the arches with 3,100 of their own stones, each one numbered as it came down and set back in the same place.

::resource{id="bridge"}

It reopened five days before the first storm of the autumn. Heavy rain is forecast for Saturday :ref{id="forecast" text="(Weather, right)"}, and the county’s engineers will be on the bridge to watch the river rise.

The first vehicle across was Keith Barlow’s dairy van, which has spent more than a year on the eleven-mile detour through Kirkby. “Forty minutes each way, six days a week,” he said. “I’d rather not add it up.”

The bridge is open to cars, vans and buses, and closed to goods vehicles over 7.5 tonnes. The county says heavy traffic had weakened the old structure long before the flood, and it will not let lorries back until the new pier has passed a load test next spring.

That has disappointed some traders. “It’s half a bridge for us,” said Priya Chandra, who runs the builders’ merchant on Mill Lane. “Our timber still comes round by Kirkby, eleven miles each way.”

For the market, it came just in time. Stallholders from the south bank have carried their stock over the footbridge for a year, and Saturday’s market will be the first in more than a year with every pitch taken.

:::callout{type="story" span="page" title="The wind farm vote"}
## Harrow was the one ward to say no {style="second"}
:::

:::paragraphs{style="flush"}
Every ward but one voted for the wind farm in Tuesday’s poll. Market gave it the biggest majority, with 69% voting yes, and Brook Lane the smallest, 736 votes to 591 :ref{id="wards" text="(Results, below)"}.
:::

Harrow, the ward beneath the ridge, voted against by 511 votes to 469, on a turnout of 76%, the highest of the five wards. “Most of the people who voted no can see the ridge from their front doors,” said its councillor, Ruth Okafor, who has asked the cooperative to meet Harrow residents before the application goes in. The first meeting is on 8 October.

Tom Ashby, the cooperative’s chair, said it had expected Harrow to be the hardest ward to win. He has offered to move the nearest turbine 200 metres further from the village, which would cost about 4% of its output, and will put both sites to Harrow residents at the October meeting.

The poll does not bind the district council, which expects to decide the planning application in the summer of 2027. Anyone may comment on it for 21 days after it is published.

:::columnbreak

## Letters

**Tea at the count.** I was one of 22 volunteers who sorted 6,297 ballots in the Corn Exchange on Tuesday night. When the urn ran dry at ten, the cricket club lent us theirs, and we finished at a quarter to twelve. Thank you to the Rotary Club for the sandwiches, and to whoever left a tin of flapjacks on the returning officer’s table.

:::paragraphs{style="sign"}
*Margaret Oyelaran, Brook Lane*
:::

:::paragraphs{style="flush"}
**The view belongs to all of us.** Those of us who walk the Wend Way every week were outvoted by people who have never climbed the ridge. I counted eleven pairs of curlews up there in May. I hope the district council will remember that the moor is not the town’s to sell, whatever the count says.
:::

:::paragraphs{style="sign"}
*David Hurst, Low Harrow*
:::

:::paragraphs{style="imprint"}
An imaginary weekly. Text CC BY 4.0. Set in Grenze Gotisch, PT Serif and Libre Franklin (SIL OFL).
:::
`; // content.en.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  'PT Serif': ['400', '400i', '700'], 'Grenze Gotisch': ['700'],
  'Libre Franklin': ['400', '600', '700', '800', '900'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all([loadImage('ridge-2400.jpg', asset('ridge-2400.jpg')),
  loadSvg('bridge.svg', bridgeSvg()), ...bars.map((b) => loadSvg(`${b.id}.svg`, b.svg))]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Newspaper front page' });

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

## アレンジ

### 短信を写真の下に置いたままにする

`content.en.md`で`placement="bottom"`を外すと、帯はフェンスのある位置、つまり写真のすぐ下にとどまり、トップ記事はその下をページの下端まで続きます。

```diff
-:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
+:::callout{type="briefs" span="page" title="In brief"}
```

## よくあるつまずき

- **:::columnsは囲みの中でしか効かず、分割されない.** :::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。
- **段間罫がページ幅の章扉を貫いて走ることがある.** postext 1.4.1では、段組みの区画の段間罫は、最も高い段の頭から始まります。ページ幅の見出しは1段目にとどまるため、その章扉がグリッドの行ちょうどで終わらないと、横に残った2段目のわずかな隙間のせいで罫が版面の頭から始まり、章扉を横切ります。章扉の深さをグリッドの整数行にしてください。デザインが短いときは、その高さの空の箱かminHeightを使います。そうすれば章扉はグリッドの行で終わり、隙間が残りません。
- **囲みの中の表や図には前後の空きがない.** postext 1.4.1では、::resourceで位置'here'に置いた表や図は、:::calloutの中では本文中で取る空きをまったく取らず、上下の段落に接します。::resourceの行の前に:::spaceを置き、あとにテキストが続くときはもう1つ置いてください。lines=0.33のように1行の端数を指定すれば、小さな空きになります。
- **'top'のフロートは参照したページには入らない.** フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。
- **ビットマップは文書のdpiでのpxで配置される。印刷サイズを宣言する.** ビットマップのリソースの大きさは、ファイルからではなく、宣言した幅と高さ（文書のdpiでのピクセル数）から決まります。印刷サイズのピクセル数（刷り上がりの幅で約300 dpi）を宣言すれば、図は正しい大きさに収まり、鮮明さも保たれます。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 写真に`placement: { position: 'top', span: 'page' }`を与えると、写真は1ページ目から2ページ目に移り、そこに単独で置かれます。すると1ページ目の記事は短信欄の92 mm上で終わり、Town & Valleyの欄は3ページ目に移ります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: Wind turbines on a snowy ridge (the front-page photograph, cropped to a band): Jason Blackeye ([出典](https://commons.wikimedia.org/wiki/File:Wind_Turbines_(Unsplash).jpg)), CC0-1.0
- 画像: The bridge drawing and the bars in the ward table, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- 書体: Grenze Gotisch (OFL-1.1), PT Serif (OFL-1.1), Libre Franklin (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 021 · 分かれる囲み、フロートする囲み、固定する囲み](https://postext.dev/ja/cookbook/boxes-split-float-pin.md): 手順の囲みが2つの段に分かれ、データシートが次のページの頭へ移り、バッジが地に固定される理科の実習ワークシート。 · 難易度 3 (上級) · ワークブックと練習問題, 教科書
- [No. 023 · 雑誌の表紙とセクション別の目次](https://postext.dev/ja/cookbook/magazine-cover-and-contents.md): 写真の表紙では表紙コピーが題字から吊り下がり、目次はセクションごとに色の行を立てて生成されます。各セクションは、ページを開かない部です。 · 難易度 3 (上級) · 雑誌とジン
- [No. 059 · 大判1ページの研究ポスター](https://postext.dev/ja/cookbook/research-poster.md): 600 × 800 mmの学会ポスター。題の帯の下に、入れ子のパネルを3段に並べたページ幅の囲みを1つ置き、ヒートマップには色見本の凡例、−4.2 °Cは150 ptで組みます。 · 難易度 3 (上級) · 論文・学術書, 一枚物とエフェメラ
