# 段末のそろった年次報告書

> エネルギー協同組合の年次報告書。両端そろえの2段組みが同じグリッド線で終わり、ページ幅の主要数値の囲みが入り、最終ページも段末がそろいます。

- HTML版: https://postext.dev/ja/cookbook/annual-report-flush-columns
- レシピ No. 037 · 完成した出版物 · 難易度 3 (上級) · 出力: Canvas
- ジャンル: 報告書
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/annual-report-flush-columns/en/p01.webp?v=c80de4fd), [2](https://postext.dev/cookbook/annual-report-flush-columns/en/p02.webp?v=c80de4fd), [3](https://postext.dev/cookbook/annual-report-flush-columns/en/p03.webp?v=c80de4fd), [4](https://postext.dev/cookbook/annual-report-flush-columns/en/p04.webp?v=c80de4fd), [5](https://postext.dev/cookbook/annual-report-flush-columns/en/p05.webp?v=c80de4fd)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=annual-report-flush-columns&lang=en (.postext: https://postext.dev/cookbook/annual-report-flush-columns/en/annual-report-flush-columns.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/annual-report-flush-columns.md), [es](https://postext.dev/es/cookbook/annual-report-flush-columns.md), [ca](https://postext.dev/ca/cookbook/annual-report-flush-columns.md), [zh](https://postext.dev/zh/cookbook/annual-report-flush-columns.md), [ar](https://postext.dev/ar/cookbook/annual-report-flush-columns.md)

## かんたんな説明

地域のエネルギー協同組合が毎年出す報告書を、2段組みで組んだ例です。左右の段の文章をぴったり同じ高さで終わらせる方法を示します。

## できあがり

Tidewell Community Energyの2025年次報告書です。風車2基、太陽光発電所1か所、21棟の屋根のパネルをもつ架空の協同組合で、判型は210 × 280 mm、全5ページ。表紙では150 ptの「2025」の下に、その年の風力と太陽光の発電量が2本のリボンになって流れます。続いて理事長の手紙、事業報告、財務担当の決算報告が、Brygada 1918の両端そろえ2段組みで、50行のグリッドに組まれます。いっぱいの段はグリッドの最終行で終わり、短い段は隣の段と高さをそろえて終わります。理事長のページには3つの主要数値を収めた囲みがページ幅で入り、手紙はその上で高さをそろえて切られます。計量についての注記は4ページ目の頭にフロートします。最終ページでは、左右の段が損益計算書の上の同じ行で止まります。パレット属性によって、事業報告のページは青緑に、財務のページはれんが色になります。

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

- 2段組みの報告書で、すべての段を最終グリッド線で終わらせ、最終ページも段末をそろえるには？
- ページの途中に、2段をまたぐ囲み（3つ並びの「数字で見る」欄など）を置くには？
- 本文を流したまま、囲みをページの上端や下端にフロートとして配置するには？
- 見出し行、結合セル、列幅、セルごとのそろえを備えた表を作るには？
- 本を部や節に分け、それぞれに専用の色と中扉を付けるには？

## 手短な答え

```js
// script.js, 行 41–56
// The text block is LINES lines of LEAD deep (MARGIN.bottom is derived from them) and every
// heading takes whole lines, so a column the break rules leave short is short by whole lines.
// Balancing (on by default) stretches it back to its foot with its levers, in this order: a
// box that closes the column moves down to it; lines above the headings; one line where a
// list ends; one line under a float at the column head; last, a paragraph set one line longer
// and looser, with up to maxTracking of tracking. A closing band whose columns differ by more
// than a line is cut level instead (trailing), and so is the band a page-wide box leaves when
// it has to move on to the next page (beforeSpan).
const balancing = { maxLinesPerHeading: 1 }; // one line per heading; the next lever takes more
const flowText = { // justification, hyphenation and Knuth–Plass stay at their defaults (on)
  fontFamily: 'Brygada 1918', fontSize: pt(9.4), lineHeight: pt(LEAD),
  firstLineIndent: mm(4), indentAfterHeading: false,
  minWordSpacing: 0.7, maxWordSpacing: 1.8, // word spaces 0.7–1.8 of normal (defaults 0.6–2)
};
const onGrid = { lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }; // one line
// hook-up: headings: { balancing, levels: [..., { level: 2, ...onGrid }] }, bodyText: flowText
```

## 材料

**学べること**

- [段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ): すべての段の末尾をページの下端にそろえます。調整は、見出し・リスト・フロートまわりのアキ、段落のゆるさとトラッキングの順に行います。章の最終ページの段の高さもそろえます。

**ほかに使うもの**

- [段抜きの囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [囲みの中の段組み](https://postext.dev/ja/docs/document-format.md#columns)
- [フロートする囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [部ごとの色](https://postext.dev/ja/docs/configuration.md#partコンテナー)
- [ウィドウ、オーファン、ラント](https://postext.dev/ja/docs/configuration.md#オーファンウィドウラント分割禁止の規則)
- [最適な改行（Knuth–Plass）](https://postext.dev/ja/docs/justification.md#knuth-plass段落全体を見渡す)
- [図の配置](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#ブロック埋め込み任意本文中への明示的な配置)
- [名前付きの表スタイル](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/document-format.md#リソース)
- [表紙、扉、奥付](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- citations
- [見出しの属性](https://postext.dev/ja/docs/document-format.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#partコンテナー)
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [タイトル内の改行](https://postext.dev/ja/docs/document-format.md#タイトル内の改行)

**設定の一覧**

- [`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#段落スタイル), [`parts`](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#名前付きの表スタイル), [`unorderedLists`](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`mergeCells`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Brygada 1918 (OFL-1.1), Epilogue (OFL-1.1), Spline Sans Mono (OFL-1.1), Mrs Saint Delafield (OFL-1.1)

## 作り方

### 1 · すべての行をグリッドに乗せ、すき間は調整手段に埋めさせる

コードは上の[手短な答え](#手短な答え)にあります。版面はちょうど13.4 ptの行が50行分の深さで、地の余白（21.6 mm）はその深さから導きます。小見出しは上に1行、自身に1行のグリッド線を使うので、段が短く終わるときの不足は行単位になり、[段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ)が足すのも行単位です。[3ページ目](https://postext.dev/cookbook/annual-report-flush-columns/en/p03.webp?v=c80de4fd)では、3行のひょうの段落が`avoidOrphans`によってまるごと4ページ目へ送られます。これは段落の最終行が段の頭にひとりで立つのを防ぐ、Postextでの規則の名前です。その結果、右段は2行短く終わります。`maxLinesPerHeading: 1`はそのうち1行を*The Saltings and the roofs*の上の空きに回し、残る1行を次の調整手段に任せます。次の手段はそれを屋根のリストの後に加えます。初期値の4のままなら、2行とも見出しの上に入ります。

### 2 · ページ幅の囲みの上で本文の高さをそろえて切る

```js
// script.js, 行 100–108
// In the Markdown: :::callout{type="figures" span="page"} around :::columns{count=3 breaks="3,5"},
// each number a :::paragraphs{style="figure"} of **8.47 GWh**, then its line of text.
const figures = { id: 'figures', background: col('ink'), columnGap: mm(GUTTER),
  padding: { top: mm(5), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: pt(LEAD), marginBottom: pt(LEAD), titleStyle: { ...boxTitle, color: col('sun') },
  body: { fontFamily: 'Epilogue', fontSize: pt(9), lineHeight: pt(12), color: col('paper'),
    ...boxText } };
const bigNumber = { id: 'figure', fontFamily: 'Epilogue', fontSize: pt(26), lineHeight: pt(31),
  color: col('paper'), boldColor: col('sun'), ...boxText };
```

`span="page"`を指定した囲みは、2段組みのページを上下の帯に分けます。[2ページ目](https://postext.dev/cookbook/annual-report-flush-columns/en/p02.webp?v=c80de4fd)では、理事長の手紙が囲みの上で左右とも8行で止まり、囲みの下で再び2段に続きます。囲みの中では、`:::columns{count=3 breaks="3,5"}`が2番目と3番目の段を2番目と3番目の数値（グループの3番目と5番目のブロック）から始めます。段の高さがいちばんそろう位置で切るのではありません。`figure`段落スタイルがこれらの数値を26 ptにし、その`boldColor`が黄色で印刷します。

### 3 · 囲みを次のページの頭へフロートさせる

```js
// script.js, 行 112–121
// In the Markdown: :::callout{type="aside" span="page" placement="top"}. It leaves the flow
// where it stands and takes the head of the next page the flow opens; the text goes on
// filling this one. It has a stripe in the section's colour and no fill. The padding under
// the text drops the floats below the box by a grid line, so they stand clear of it.
const aside = { id: 'aside', backgroundEnabled: false, columnGap: mm(GUTTER),
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') },
  padding: { top: mm(3.5), right: pt(0), bottom: mm(3.5), left: pt(0) },
  titleStyle: { ...boxTitle, color: col('band') },
  body: { fontFamily: 'Epilogue', fontSize: pt(8.6), lineHeight: pt(12.2), color: col('ink'),
    ...boxText } };
```

Markdownの中で、計量についての注記はHarrow Downについての最初の段落の後、3ページ目にあります。`placement="top"`はこれを流れから取り出し、次のページの頭に置きます。報告は3ページ目の残りを埋め、[4ページ目](https://postext.dev/cookbook/annual-report-flush-columns/en/p04.webp?v=c80de4fd)は2段にまたがる囲みで始まります。その下で、拠点の表とリンググラフ（どちらも`position: 'top'`）がそれぞれ1段の頭に入ります。囲みの下パディング3.5 mmがあるため、囲みがない場合より1グリッド線低い位置です。

### 4 · 区切りページなしで節ごとに色を変える

```js
// script.js, 行 14–27
const palette = {
  ink: '#14202b', // text: a blue-black
  band: '#0b5d7a', // sea, overridden by palette="band=#…" on the :::part fences
  wind: '#3aa6a0', sun: '#f2b134', coral: '#e2674b', // the data colours of the charts
  tint: '#eef3f5', // subtotal rows
  rule: '#c9d3d9', // hairlines
  muted: '#566370', // captions' notes and the running feet
  mist: '#9fb1bd', // small print on the ink cover
  paper: '#ffffff',
};
// Designs paint the hex (gotcha: palette-skips-designs); a :::part recolours by paletteId.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

`parts: { page: false }`のもとでは、`:::part{number="02" title="Operations" palette="band=#1c6e67"}`のようなフェンスはページを開きません。節の冒頭の帯と地側の柱に出す節の名前を決め、次の部まですべてのページで`band`を差し替えます。帯、小見出し、行頭記号、キャプションのラベル、傍らの囲みの罫がすべて`band`を使うので、事業報告のページは青緑、財務のページはれんが色で刷られ、理事長の手紙は基本の海の青のままです（[部ごとの色](https://postext.dev/ja/docs/configuration.md#partコンテナー)）。

### 5 · 表をデータから組み、最終ページの段末をそろえる

```js
// script.js, 行 169–218
const DATA = {
  // Output in MWh at the export meter, January to December 2025.
  wind: [560, 520, 450, 330, 270, 210, 190, 150, 300, 420, 480, 540], // Harrow Down
  sun: [90, 160, 300, 440, 560, 600, 580, 500, 380, 250, 120, 70], // the Saltings + 21 roofs
  // The turbines' year, % of the hours of both machines: [label, share, palette colour].
  hours: [['generating', 78.0, 'wind'], ['waiting for wind', 16.8, 'rule'], ['bearing repair', 2.6,
    'coral'], ['servicing and grid', 1.8, 'sun'], ['stopped for bats', 0.8, 'ink']],
  // Output by site in MWh: [site, source, capacity in MW, 2025, 2024].
  sites: [['Harrow Down', 'wind', 1.8, 4420, 4560], ['The Saltings', 'solar', 3.2, 3190, 2640],
    ['21 roofs', 'solar', 0.9, 860, 790]],
  // £ thousand, [label, 2025, 2024]; a label alone opens a group, '=' prints the running sum.
  accounts: [['Income'], ['Electricity sold under the power purchase agreement', 760, 722],
    ['Electricity sold to roof hosts', 92, 85], ['Feed-in tariff', 236, 229], ['=Total income'],
    ['Operating costs'], ['Operation and maintenance', -231, -198],
    ['Rent, rates and insurance', -158, -151], ['Staff and administration', -121, -112],
    ['Depreciation', -286, -286], ['=Operating surplus'],
    ['Interest on the Harrow Down loan', -54, -66], ['Interest on members’ shares at 3.5%', -113,
      -107], ['Grants to the Tidewell Fund', -84, -70], ['Corporation tax', -5, -7],
    ['=Surplus for the year']],
};
const fill = { background: col('tint') }; // totals sit on a tint between two hairlines
const right = (content, extra) => ({ content, align: 'right', ...extra });
const figure = (n, digits = 0) => n.toLocaleString('en-GB', { minimumFractionDigits: digits });
const head = (c, i) => (i ? right(c, { isHeader: true }) : { content: c, isHeader: true });
const siteTable = (rows) => ({ headerRowCount: 1, columnWidths: [3, 1, 1.3, 1.3], rows: [
  ['Site', 'MW', '2025', '2024'].map(head),
  ...rows.map(([site, source, mw, ...n]) => [{ content: `${site} *(${source})*` },
    right(figure(mw, 1)), ...n.map((v) => right(figure(v)))]),
  [{ content: '**All sites**', ...fill }, ...[2, 3, 4].map((i, k) => right(`**${figure(rows
    .reduce((sum, row) => sum + row[i], 0), k ? 0 : 1)}**`, fill))]] }); // the totals, summed
// Accounting style: losses in brackets, and gains followed by a no-break space as wide as a
// bracket, so the digits line up. Cells are trimmed, so a word joiner (U+2060) keeps it.
const pad = '\u00a0\u2060';
const money = (n) => (n < 0 ? `(${figure(-n)})` : `${figure(n)}${pad}`);
function statement(rows) {
  const sum = [0, 0];
  const cells = [['£ thousand', `2025${pad}`, `2024${pad}`].map(head)];
  for (const [label, ...years] of rows) {
    years.forEach((n, i) => { sum[i] += n; });
    const sub = label.startsWith('='); // a subtotal: the running sum, in bold
    cells.push(sub ? [{ content: `**${label.slice(1)}**`, ...fill },
      ...sum.map((n) => right(`**${money(n)}**`, fill))]
      : [{ content: years.length ? label : `*${label}*` }, // a label alone heads a group
        ...[0, 1].map((i) => right(years.length ? money(years[i]) : ''))]);
  }
  // mergeCells writes the cells a spanning group head hides (gotcha: merged-cells-hiddenby)
  return cells.reduce((model, row, r) => (r && !row[1].content ? mergeCells(model,
    { start: { row: r, col: 0 }, end: { row: r, col: 2 } }) : model),
  { rows: cells, headerRowCount: 1, columnWidths: [5, 1, 1] });
}
```

`statement()`は`DATA.accounts`を1行ずつ損益計算書に変えます。`=`で始まるラベルは各年度の累計を淡い地色の上に太字で印刷します。費用はデータ上で負の値なので、*Operating surplus*はその累計から出てきて、手で打ち込む合計はありません。数値のないラベル（*Income*や*Operating costs*）は、表の幅いっぱいのイタリックのセル1つになります。結合は`mergeCells`で行い、結合に必要なプレースホルダーのセルもこれが書き出します。[5ページ目](https://postext.dev/cookbook/annual-report-flush-columns/en/p05.webp?v=c80de4fd)では、計算書が文書を締めくくる財務担当の報告の下、ページの地側にページ幅でフロートします。段末をそろえなければ、左段は12行、右段は9行になります。末尾の切りそろえで両段を11行にし、見出しの調整手段が右段にまだ足りない1行を*Cash and reserves*の上に加えます。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/annual-report-flush-columns

### script.js

```js
// ═══ Postext Cookbook · Nº 037 · Annual report with flush columns ═══════════════════════
// https://postext.dev/en/cookbook/annual-report-flush-columns
// Code: MIT · Text: original (CC BY 4.0) · Art: drawn in code · Typefaces: SIL OFL 1.1
// Fonts: Brygada 1918, Epilogue, Spline Sans Mono, Mrs Saint Delafield · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, mergeCells,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: 'band' is the first section's colour; each later :::part brings its own
const palette = {
  ink: '#14202b', // text: a blue-black
  band: '#0b5d7a', // sea, overridden by palette="band=#…" on the :::part fences
  wind: '#3aa6a0', sun: '#f2b134', coral: '#e2674b', // the data colours of the charts
  tint: '#eef3f5', // subtotal rows
  rule: '#c9d3d9', // hairlines
  muted: '#566370', // captions' notes and the running feet
  mist: '#9fb1bd', // small print on the ink cover
  paper: '#ffffff',
};
// Designs paint the hex (gotcha: palette-skips-designs); a :::part recolours by paletteId.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TRIM = { width: 210, height: 280 };
const LEAD = 13.4; // body leading in pt: one line of the baseline grid
const LINES = 50; // grid lines in a full column
const MARGIN = { top: 22, inner: 18, outer: 16 }; // mm, mirrored
MARGIN.bottom = TRIM.height - MARGIN.top - (LINES * LEAD * 25.4) / 72; // 21.6 mm: 50 lines exactly
const GUTTER = 7; // mm between the columns, and between the columns inside the boxes
const mono = { fontFamily: 'Spline Sans Mono', fontWeight: 500, textTransform: 'uppercase' };
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, placement,
  align: 'left', ...look });

// #region answer: flush columns: whole grid lines everywhere, and the balancing levers
// The text block is LINES lines of LEAD deep (MARGIN.bottom is derived from them) and every
// heading takes whole lines, so a column the break rules leave short is short by whole lines.
// Balancing (on by default) stretches it back to its foot with its levers, in this order: a
// box that closes the column moves down to it; lines above the headings; one line where a
// list ends; one line under a float at the column head; last, a paragraph set one line longer
// and looser, with up to maxTracking of tracking. A closing band whose columns differ by more
// than a line is cut level instead (trailing), and so is the band a page-wide box leaves when
// it has to move on to the next page (beforeSpan).
const balancing = { maxLinesPerHeading: 1 }; // one line per heading; the next lever takes more
const flowText = { // justification, hyphenation and Knuth–Plass stay at their defaults (on)
  fontFamily: 'Brygada 1918', fontSize: pt(9.4), lineHeight: pt(LEAD),
  firstLineIndent: mm(4), indentAfterHeading: false,
  minWordSpacing: 0.7, maxWordSpacing: 1.8, // word spaces 0.7–1.8 of normal (defaults 0.6–2)
};
const onGrid = { lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }; // one line
// hook-up: headings: { balancing, levels: [..., { level: 2, ...onGrid }] }, bodyText: flowText
// #endregion

// #region openers: the cover and the section openers, fed by heading attributes
const cover = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'field', style: { backgroundColor: col('ink') },
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
  { kind: 'image', id: 'ribbons', resourceId: 'ribbons', // 210 × 128 mm, from DATA
    placement: { ...at('bleed', 'top-left', 0, 104), size: { width: 'fill' } } },
  text('name', '{titleText}', { fontFamily: 'Epilogue', fontWeight: 800, fontSize: pt(19),
    color: col('paper') }, at('page', 'top-left', MARGIN.inner, MARGIN.top)),
  text('year', '{attr.year}', { fontFamily: 'Epilogue', fontWeight: 800, fontSize: pt(150),
    lineHeight: 0.9, letterSpacing: pt(-2), color: col('paper') }, at('#name', 'below', -2, 4)),
  text('strap', '{attr.strap}', { fontFamily: 'Epilogue', fontSize: pt(19), color: col('sun') },
    at('#year', 'below', 2, 2)),
  text('period', '{attr.period}', { ...mono, fontSize: pt(7.5), letterSpacing: pt(1.3),
    color: col('paper') }, at('#strap', 'below', 0, 3)),
  text('note', '{attr.note}', { fontFamily: 'Epilogue', fontSize: pt(6.5), color: col('mist'),
    overflow: 'wrap' }, { ...at('page', 'bottom-left', MARGIN.inner, -14), // the text block's
    size: { width: mm(TRIM.width - MARGIN.inner - MARGIN.outer) } }), // width: 'fill' hits the trim
] } };
// A section opener: a strip in the part's colour, then title and standfirst. Design text's
// lineHeight is a multiple of its size (gotcha: design-lineheight-multiple).
const STRIP = 8; // mm
const onStrip = { ...mono, fontSize: pt(8), letterSpacing: pt(1.4), color: col('paper') };
const opener = { enabled: true, minHeight: mm(56), slot: { elements: [
  { kind: 'box', id: 'strip', style: { backgroundColor: col('band') },
    placement: { ...at('container', 'top-left'), size: { width: 'fill', height: mm(STRIP) } } },
  text('part', '{partNumber}   {partTitle}', onStrip, // a text given a height centres on it
    { ...at('container', 'top-left', 3), size: { height: mm(STRIP) } }),
  text('kicker', '{attr.kicker}', { ...onStrip, align: 'right' },
    { ...at('container', 'top-right', -3), size: { height: mm(STRIP) } }),
  text('title', '{titleText}', { fontFamily: 'Epilogue', fontWeight: 800, fontSize: pt(26),
    lineHeight: 1.04, color: col('ink'), overflow: 'wrap' }, // breaks at the title's \\
  { ...at('#strip', 'below', 0, 8), size: { width: 'fill' } }),
  text('standfirst', '{attr.standfirst}', { fontFamily: 'Brygada 1918', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), overflow: 'wrap' },
  { ...at('#title', 'below', 0, 3.5), size: { width: mm(150) } }),
] } };
// #endregion

const boxText = { textAlign: 'left', firstLineIndent: pt(0) };
const boxTitle = { ...mono, fontSize: pt(7.5), letterSpacing: pt(1.3), gap: mm(3) };
// #region figures: a page-wide box of three key figures, one to a column
// In the Markdown: :::callout{type="figures" span="page"} around :::columns{count=3 breaks="3,5"},
// each number a :::paragraphs{style="figure"} of **8.47 GWh**, then its line of text.
const figures = { id: 'figures', background: col('ink'), columnGap: mm(GUTTER),
  padding: { top: mm(5), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: pt(LEAD), marginBottom: pt(LEAD), titleStyle: { ...boxTitle, color: col('sun') },
  body: { fontFamily: 'Epilogue', fontSize: pt(9), lineHeight: pt(12), color: col('paper'),
    ...boxText } };
const bigNumber = { id: 'figure', fontFamily: 'Epilogue', fontSize: pt(26), lineHeight: pt(31),
  color: col('paper'), boldColor: col('sun'), ...boxText };
// #endregion

// #region aside: a box that floats to the head of the next page while the text flows on
// In the Markdown: :::callout{type="aside" span="page" placement="top"}. It leaves the flow
// where it stands and takes the head of the next page the flow opens; the text goes on
// filling this one. It has a stripe in the section's colour and no fill. The padding under
// the text drops the floats below the box by a grid line, so they stand clear of it.
const aside = { id: 'aside', backgroundEnabled: false, columnGap: mm(GUTTER),
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') },
  padding: { top: mm(3.5), right: pt(0), bottom: mm(3.5), left: pt(0) },
  titleStyle: { ...boxTitle, color: col('band') },
  body: { fontFamily: 'Epilogue', fontSize: pt(8.6), lineHeight: pt(12.2), color: col('ink'),
    ...boxText } };
// #endregion

// Running feet: the folio outside, the report on the verso, the section in its colour opposite.
const foot = (id, content, parity, x, look = {}) => text(id, content, { ...mono, fontSize: pt(7),
  letterSpacing: pt(1.1), color: col('muted'), parity, align: x < 0 ? 'right' : 'left', ...look },
at('page', x < 0 ? 'bottom-right' : 'bottom-left', x, -11));
const folio = { fontWeight: 700, color: col('ink') };
const footer = { elements: [
  foot('verso-folio', '{pageNumber}', 'even', MARGIN.outer, folio),
  foot('verso-title', '{title}  ·  {subtitle}', 'even', MARGIN.outer + 8),
  foot('recto-folio', '{pageNumber}', 'odd', -MARGIN.outer, folio),
  foot('recto-part', '{partTitle}', 'odd', -(MARGIN.outer + 8), { color: col('band') }),
] };

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes,
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { gutterWidth: mm(GUTTER) }, // two columns, the default
  // Bold, italic and references default to the engine's blue, so all three are restated in ink.
  bodyText: { ...flowText, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink'), referenceBold: false },
  headings: { fontFamily: 'Epilogue', color: col('band'), balancing, levels: [
    // Restated (gotcha: headings-drop-h1-break); 'any': a section opens on the next page.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener },
    { level: 2, fontSize: pt(11.5), fontWeight: 700, ...onGrid },
  ] },
  headingStyles: [{ id: 'cover', advancedDesign: cover, footer: { elements: [] } }], // no folio
  parts: { page: false }, // a :::part sets the section's title and colour, with no page
  unorderedLists: { bulletChar: '–', color: col('band'), marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [figures, aside],
  paragraphStyles: [bigNumber, { id: 'signoff', fontFamily: 'Epilogue', fontSize: pt(8.5),
    ...boxText }],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Spline Sans Mono',
    headerFontSize: pt(7.5), bodyFontFamily: 'Spline Sans Mono', bodyFontSize: pt(7.8),
    bodyColor: col('ink'), cellPadding: mm(1.3) },
  tableStyles: [{ id: 'statement', cellPadding: mm(0.9) }], // 17 rows, set closer
  captionStyle: { fontFamily: 'Epilogue', fontSize: pt(8), color: col('ink'), gap: mm(2.5),
    labelColor: col('band'), note: { fontSize: pt(7), color: col('muted') } },
  header: { elements: [] }, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region data: one object for the cover, both charts and the income statement
const DATA = {
  // Output in MWh at the export meter, January to December 2025.
  wind: [560, 520, 450, 330, 270, 210, 190, 150, 300, 420, 480, 540], // Harrow Down
  sun: [90, 160, 300, 440, 560, 600, 580, 500, 380, 250, 120, 70], // the Saltings + 21 roofs
  // The turbines' year, % of the hours of both machines: [label, share, palette colour].
  hours: [['generating', 78.0, 'wind'], ['waiting for wind', 16.8, 'rule'], ['bearing repair', 2.6,
    'coral'], ['servicing and grid', 1.8, 'sun'], ['stopped for bats', 0.8, 'ink']],
  // Output by site in MWh: [site, source, capacity in MW, 2025, 2024].
  sites: [['Harrow Down', 'wind', 1.8, 4420, 4560], ['The Saltings', 'solar', 3.2, 3190, 2640],
    ['21 roofs', 'solar', 0.9, 860, 790]],
  // £ thousand, [label, 2025, 2024]; a label alone opens a group, '=' prints the running sum.
  accounts: [['Income'], ['Electricity sold under the power purchase agreement', 760, 722],
    ['Electricity sold to roof hosts', 92, 85], ['Feed-in tariff', 236, 229], ['=Total income'],
    ['Operating costs'], ['Operation and maintenance', -231, -198],
    ['Rent, rates and insurance', -158, -151], ['Staff and administration', -121, -112],
    ['Depreciation', -286, -286], ['=Operating surplus'],
    ['Interest on the Harrow Down loan', -54, -66], ['Interest on members’ shares at 3.5%', -113,
      -107], ['Grants to the Tidewell Fund', -84, -70], ['Corporation tax', -5, -7],
    ['=Surplus for the year']],
};
const fill = { background: col('tint') }; // totals sit on a tint between two hairlines
const right = (content, extra) => ({ content, align: 'right', ...extra });
const figure = (n, digits = 0) => n.toLocaleString('en-GB', { minimumFractionDigits: digits });
const head = (c, i) => (i ? right(c, { isHeader: true }) : { content: c, isHeader: true });
const siteTable = (rows) => ({ headerRowCount: 1, columnWidths: [3, 1, 1.3, 1.3], rows: [
  ['Site', 'MW', '2025', '2024'].map(head),
  ...rows.map(([site, source, mw, ...n]) => [{ content: `${site} *(${source})*` },
    right(figure(mw, 1)), ...n.map((v) => right(figure(v)))]),
  [{ content: '**All sites**', ...fill }, ...[2, 3, 4].map((i, k) => right(`**${figure(rows
    .reduce((sum, row) => sum + row[i], 0), k ? 0 : 1)}**`, fill))]] }); // the totals, summed
// Accounting style: losses in brackets, and gains followed by a no-break space as wide as a
// bracket, so the digits line up. Cells are trimmed, so a word joiner (U+2060) keeps it.
const pad = '\u00a0\u2060';
const money = (n) => (n < 0 ? `(${figure(-n)})` : `${figure(n)}${pad}`);
function statement(rows) {
  const sum = [0, 0];
  const cells = [['£ thousand', `2025${pad}`, `2024${pad}`].map(head)];
  for (const [label, ...years] of rows) {
    years.forEach((n, i) => { sum[i] += n; });
    const sub = label.startsWith('='); // a subtotal: the running sum, in bold
    cells.push(sub ? [{ content: `**${label.slice(1)}**`, ...fill },
      ...sum.map((n) => right(`**${money(n)}**`, fill))]
      : [{ content: years.length ? label : `*${label}*` }, // a label alone heads a group
        ...[0, 1].map((i) => right(years.length ? money(years[i]) : ''))]);
  }
  // mergeCells writes the cells a spanning group head hides (gotcha: merged-cells-hiddenby)
  return cells.reduce((model, row, r) => (r && !row[1].content ? mergeCells(model,
    { start: { row: r, col: 0 }, end: { row: r, col: 2 } }) : model),
  { rows: cells, headerRowCount: 1, columnWidths: [5, 1, 1] });
}
// #endregion

// Charts and tables numbered through the report, tables captioned above; marks go unnumbered.
const type = (id, name, extra) => ({ id, name, captionPrefix: name,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...extra });
const resourceTypes = [type('chart', 'Chart'), type('table', 'Table', { captionStyle:
  { position: 'above' } }), type('mark', 'Mark', { captionPrefix: '', numberingTemplate: '' })];
const svg = (id, typeId, fileId, [width, height], extra) => ({ id, typeId, kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId, width, height }, ...extra });
const table = (id, model, { styleId, ...extra }) => ({ id, typeId: 'table', kind: 'table',
  createdAt: 0, updatedAt: 0, table: { model, styleId }, ...extra });
const resources = [
  svg('ribbons', 'mark', 'ribbons.svg', [2100, 1280], { altText: 'A teal and a yellow ribbon '
    + 'swell and cross from January to December with the wind and sun output.' }),
  svg('monthly', 'chart', 'monthly.svg', [1760, 560], { placement: { position: 'bottom',
    span: 'page' }, note: 'Measured at the export meters. Turbine 2 stood still 4–23 August.',
  caption: 'Output by month in 2025, in megawatt-hours: :swatch{color="wind"} wind at Harrow '
    + 'Down and :swatch{color="sun"} sun on the Saltings and the 21 roofs.',
  altText: 'Paired bars by month: wind falls from 560 MWh in January to 150 in August, sun peaks '
    + 'at 600 in June.' }),
  svg('hours', 'chart', 'hours.svg', [845, 470], { placement: { position: 'top' },
    caption: 'How the two turbines spent the 17,520 hours of their year.',
    altText: DATA.hours.map(([label, share]) => `${label} ${share.toFixed(1)}%`).join(', ') }),
  svg('signature', 'mark', 'signature.svg', [420, 150], { placement: { position: 'here',
    width: 0.42 }, altText: 'The chair’s signature.' }),
  table('sites', siteTable(DATA.sites), { placement: { position: 'top' }, // the house style
    caption: 'Output by site, in megawatt-hours.' }),
  table('accounts', statement(DATA.accounts), { placement: { position: 'bottom', span: 'page' },
    caption: 'Income statement for the year to 31 December.', styleId: 'statement',
    note: 'Audited. Figures in brackets are costs; the full accounts are available on request.' }),
];

const markdown = String.raw`---
title: "Tidewell Community Energy"
subtitle: "Annual report and accounts 2025"
author: "Tidewell Community Energy"
---

# Tidewell Community Energy {style="cover" year="2025" strap="Annual report and accounts" period="For the year to 31 December 2025" note="The cover draws our output month by month, January to December: wind in teal, sun in yellow. Tidewell is a fictional co-operative, and every name and figure in this report is invented. Set in Brygada 1918, Epilogue, Spline Sans Mono and Mrs Saint Delafield (SIL OFL)."}

:::part{number="01" title="The year"}
:::

# The wind and the sun \\ took turns {kicker="From the chair" standfirst="Output rose 6% to 8.47 gigawatt-hours, 188 people joined the co-op and the Tidewell Fund gave £84,000 to 23 projects in the town."}

Dear members,

Last January the turbines on Harrow Down had their windiest month since we put them up in 2015, and in June the panels on the Saltings and on 21 roofs around the town made more electricity than in any month we have metered. Together they made 8.47 gigawatt-hours in the year, 6% more than in 2024. At the regulator’s figure of 2,700 kilowatt-hours for a typical home, that is the yearly use of about 3,100 households, more than there are in Tidewell itself.

The board was most pleased by how evenly the output was spread over the year. The turbines are strongest from October to March and the panels from April to September, and in 2025 each covered for the other so well that no month fell below 600 megawatt-hours. The operations report shows the two side by side.

:::callout{type="figures" span="page" title="2025 in three numbers"}
:::columns{count=3 breaks="3,5"}
:::paragraphs{style="figure"}
**8.47 GWh**
:::

generated by our turbines and panels, 6% more than in 2024

:::paragraphs{style="figure"}
**2,316**
:::

members at 31 December, 188 of them new this year

:::paragraphs{style="figure"}
**£84,000**
:::

granted by the Tidewell Fund to 23 projects in the town
:::
:::

The costliest setback came in August, when the main bearing of turbine 2 began to fail and the machine stood still for 19 days while a crane crew replaced it. Our insurer paid most of the bill, but the part it did not cover, together with the lost output, is the main reason why the surplus is a little smaller than last year’s. The treasurer explains the effect on the accounts in her report.

Membership grew to 2,316 by the end of December. In all, 188 people joined, most of them through the share offer we ran with the three primary schools in May, and 31 withdrew their shares, nearly all of them because they had moved away. Our youngest member is nine; her shares were a birthday present. We paid 3.5% interest on shares for the eighth year running. The Tidewell Fund, which takes a share of each year’s surplus, made 23 grants worth £84,000. They paid for loft insulation in the scout hut and the chapel hall, a library of electric cargo bikes and a warm-homes advice service that visited 140 households last winter.

In the coming year we will ask you to approve our largest investment since the Saltings: a battery beside the solar field that stores 2 megawatt-hours, so that we can sell the midday output in the evening, when the grid pays most for it. The district council gave planning permission in November. The board will present the business case at the annual general meeting on 14 May, and members will decide there by a simple majority.

Volunteers read the 21 roof meters every month, and eleven of them share the shifts on our stall at the Saturday market. Thank you to them, to the staff in the office on Quay Street, and to every member for trusting the co-op with their savings. I look forward to seeing many of you in May.

::resource{id="signature"}

:::paragraphs{style="signoff"}
Maren Coles, chair of the board
:::

:::part{number="02" title="Operations" palette="band=#1c6e67"}
:::

# Harrow Down, the Saltings \\ and 21 roofs {kicker="Operations report" standfirst="How two wind turbines, a solar field and the panels on schools, halls and the fire station ran in 2025. By Dev Okafor, operations manager."}

Our three sites generated 8,470 megawatt-hours of electricity in 2025, up from 7,990 the year before (:ref{id="monthly"}). The turbines made 4,420 of them and the panels 4,050: it is the first year in which the sun came within a tenth of the wind. Every figure in this report is measured at the export meter, and the box overleaf explains what that means.

## Harrow Down

The two 900-kilowatt turbines ran at an average capacity factor of 28.0%, against 28.8% in 2024. January was their windiest month since they were commissioned in 2015, at 560 megawatt-hours, with a gust of 31 metres a second recorded at hub height on the 24th. The storm cost us nothing but a tripped breaker at the substation, reset within the hour.

:::callout{type="aside" span="page" placement="top" title="How we count a kilowatt-hour"}
:::columns{count=2 breaks="2"}
Every site has two meters. The turbines and inverters log what they generate, and a meter owned by the grid operator records what leaves the site. The figures in this report are the second kind: what the grid bought from us, after the site’s own use and the losses in cables and transformers, which come to about 2% at Harrow Down.

The grid operator reads the export meters every half hour, and each month we check its readings against our own logs. Where the two disagree by more than 1%, the operator’s reading stands, because it is the one we are paid for. Roof hosts are billed from their own meters, read by a volunteer on the first Saturday of each month.
:::
:::

Turbine 2 lost 19 days in August. A vibration alarm on 4 August led to an inspection that found spalling on the races of the main bearing, and we stopped the machine rather than risk the gearbox behind it. The replacement came from the manufacturer’s store in Bremen, a crane crew fitted it in two days of calm weather, and the turbine was back in service on 23 August. Turbine 1 ran through the summer, stopping only for its scheduled service in June.

## The Saltings and the roofs

The solar field produced 3,190 megawatt-hours from its 3.2 megawatts of panels, or 997 kilowatt-hours for every kilowatt installed, its best since it opened in 2019. June was the strongest month on record for our panels, at 600 megawatt-hours across all sites.

On the 21 roofs the panels made 860 megawatt-hours (:ref{id="sites"}). The three largest roofs were:

- Tidewell Academy, 212 megawatt-hours;
- the leisure centre, 148;
- the fire station, 61.

Roof hosts used 610 megawatt-hours on site, at 15p a kilowatt-hour, well below what a supplier would charge, and the rest went to the grid.

In March a hailstorm cracked 38 panels at Saltmarsh Primary. The installer replaced them under warranty within a fortnight.

## Availability

The turbines were available for 94.8% of the hours in the year, down from 97.9% in 2024 (:ref{id="hours"}). The hours they could not run, counted across both machines, were lost to:

- the bearing repair on turbine 2, 2.6% of the year;
- servicing and grid outages, 1.8%;
- the summer nights stopped for bats, 0.8%: both turbines stop on warm, calm nights from May to September, when bats feed around the towers, as the planning consent requires.

For the rest of the year the turbines were either generating, 78.0% of the hours, or waiting with the wind below the 3 metres a second they need to start turning. The maintenance contract with the manufacturer guarantees 97% availability, and it pays us for the output lost below that level: £21,000 for 2025, which appears in the accounts as a reduction in maintenance costs.

## Grid limits

On eleven sunny afternoons in May and June the distribution network operator asked us to cap the Saltings’ export at half its capacity while it rebuilt the overhead line at Marsh End. It paid us for the 64 megawatt-hours we could not export, at the price in our power purchase agreement; they are left out of this report’s output figures. The rebuilt line can carry the field’s full output, so the caps should not return.

## The sites

Nobody was hurt at work on our sites in 2025, our tenth year without a lost-time accident. A flock of 140 Southdown ewes kept the grass short under the panels on the Saltings from March to October; they belong to a farmer in the next parish, who pays no rent for the grazing. In June the ecologist’s survey counted 31 skylark territories in the field margins, against 24 before the field was built. Three schools and a Scout group came to the open day at Harrow Down in September.

## The year ahead

Turbine 1 is due the ten-year inspection of its gearbox in April, and we have booked the work for a week with a low wind forecast. On the Saltings we will replace the fence along the sea wall, which the winter tides have undermined in two places, and plant the hawthorn hedge that the planning consent asked for. If members approve the battery in May, the connection works would start in the autumn, and the battery could be storing the field’s midday output by the summer of 2027.

:::part{number="03" title="Finances" palette="band=#b0452c"}
:::

# Paying 3.5% and still \\ adding to reserves {kicker="Treasurer’s report" standfirst="Income rose 5% to £1.09 million. After interest, grants and tax, the co-op kept a surplus of £36,000. By Alison Pryce, treasurer."}

Our income for the year was £1,088,000, 5% more than in 2024 (:ref{id="accounts"}). Almost all of the increase came from selling more electricity: the price set in our power purchase agreement hardly changed at its review in April.

Costs rose by £49,000. Operation and maintenance came to £231,000, £33,000 more than in 2024: the bearing repair cost us £54,000 after insurance, and the manufacturer’s availability guarantee paid back £21,000.

Out of an operating surplus of £292,000, the co-op paid £54,000 of interest on the loan that built Harrow Down, which ends in 2031, and £113,000 of interest on members’ shares. The board then gave £84,000 to the Tidewell Fund and £5,000 went in corporation tax, leaving £36,000 to add to reserves.

## Cash and reserves

At the end of the year the co-op held £612,000 in cash, of which £400,000 is set aside for the gearbox overhauls due in 2027 and 2028. Members’ share capital stood at £3.24 million: the 188 new members bought £94,000 of shares and the 31 who left withdrew £64,000.
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the cover's ribbons and the charts, drawn from DATA with the page's palette
const MONTHS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'];
const n2 = (v) => +v.toFixed(2);
// An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so each
// drawing carries its face inline, as a data URL of the Fontsource file.
async function inlineFace(family, weight) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}`
    + '-normal.woff2';
  const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return `<style>@font-face{font-family:F;src:url(data:font/woff2;base64,${btoa(bin)}) `
    + `format('woff2')}text{font-family:F}</style>`;
}
// A smooth path through points (Catmull–Rom turned into cubic Béziers).
function smooth(pts) {
  let d = `M${n2(pts[0][0])} ${n2(pts[0][1])}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const [p0, p1, p2, p3] = [pts[i - 1] ?? pts[i], pts[i], pts[i + 1], pts[i + 2] ?? pts[i + 1]];
    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
    d += `C${n2(c1[0])} ${n2(c1[1])} ${n2(c2[0])} ${n2(c2[1])} ${n2(p2[0])} ${n2(p2[1])}`;
  }
  return d;
}
// The cover: each source is a ribbon as thick as its month's output; the stronger one rides
// higher, so they cross twice: in spring and in autumn. 210 × 128 mm, the months 17.5 mm apart.
function ribbonsArt(face) {
  const [W, H, MID, THICK, SPREAD] = [210, 128, 64, 0.065, 0.09]; // mm, mm per MWh
  const x = (i) => 8.75 + i * 17.5;
  const edge = (i) => [-8.75, ...DATA.wind.map((_, m) => x(m)), W + 8.75][i];
  const layer = (own, other, colour) => {
    const pts = [own[0], ...own, own[11]].map((v, i) => {
      const m = Math.min(11, Math.max(0, i - 1));
      return [edge(i), MID - (v - other[m]) * SPREAD, (v * THICK) / 2];
    });
    let strands = '';
    for (const k of [-0.66, -0.33, 0, 0.33, 0.66]) {
      strands += `<path d="${smooth(pts.map(([px, py, h]) => [px, py + k * h]))}" fill="none" `
        + `stroke="${palette.ink}" stroke-opacity="0.18" stroke-width="0.3"/>`;
    }
    const top = pts.map(([px, py, h]) => [px, py - h]);
    const bottom = pts.map(([px, py, h]) => [px, py + h]).reverse();
    return `<path d="${smooth(top)}L${smooth(bottom).slice(1)}Z" fill="${colour}" `
      + `fill-opacity="0.9"/>${strands}`;
  };
  const ticks = MONTHS.map((m, i) => `<circle cx="${x(i)}" cy="${H - 12}" r="0.7" `
    + `fill="${palette.mist}"/><text x="${x(i)}" y="${H - 5}" font-size="3" text-anchor="middle" `
    + `fill="${palette.mist}">${m}</text>`).join('');
  // Each ribbon is labelled inside the text block: the wind in February, the sun in June.
  const label = (name, m, own, other, ink) => `<text x="${x(m)}" y="${n2(MID - (own[m] - other[m])
    * SPREAD + 1.2)}" font-size="3.4" letter-spacing="0.6" text-anchor="middle" fill="${ink}">`
    + `${name}</text>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${face}${layer(DATA.sun, DATA.wind, palette.sun)}`
    + `${layer(DATA.wind, DATA.sun, palette.wind)}${ticks}`
    + `${label('WIND', 1, DATA.wind, DATA.sun, palette.ink)}`
    + `${label('SUN', 5, DATA.sun, DATA.wind, palette.ink)}</svg>`;
}
// Chart 1: paired bars on a 200 MWh grid, 176 × 56 mm (the width of the text block).
function monthlyArt(face) {
  const [W, H, LEFT, BASE, TOPV] = [176, 56, 12, 48, 700];
  const y = (v) => BASE - (v / TOPV) * (BASE - 2);
  const step = (W - LEFT) / 12;
  let grid = '';
  for (const v of [0, 200, 400, 600]) {
    grid += `<path d="M${LEFT} ${n2(y(v))}H${W}" stroke="${v ? palette.rule : palette.ink}" `
      + `stroke-width="${v ? 0.2 : 0.35}"/><text x="${LEFT - 2}" y="${n2(y(v) + 1)}" `
      + `font-size="2.6" text-anchor="end" fill="${palette.muted}">${v}</text>`;
  }
  const bars = MONTHS.map((m, i) => {
    const cx = LEFT + step * (i + 0.5);
    const bar = (v, dx, fill) => `<rect x="${n2(cx + dx)}" y="${n2(y(v))}" width="4.4" `
      + `height="${n2(BASE - y(v))}" fill="${fill}"/>`;
    return bar(DATA.wind[i], -4.6, palette.wind) + bar(DATA.sun[i], 0.2, palette.sun)
      + `<text x="${n2(cx)}" y="${BASE + 5}" font-size="2.8" text-anchor="middle" `
      + `fill="${palette.ink}">${m}</text>`;
  }).join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${face}${grid}${bars}</svg>`;
}
// Chart 2: a ring of the turbines' hours with its key beside it, 84.5 × 47 mm (one column).
function hoursArt(face) {
  const [W, H, CX, CY, R, T] = [84.5, 47, 22, 23.5, 20, 7];
  let a0 = -Math.PI / 2;
  let ring = '';
  let key = '';
  DATA.hours.forEach(([label, share, colour], i) => {
    const a1 = a0 + (share / 100) * 2 * Math.PI;
    const p = (a, r) => `${n2(CX + r * Math.cos(a))} ${n2(CY + r * Math.sin(a))}`;
    const big = a1 - a0 > Math.PI ? 1 : 0;
    ring += `<path d="M${p(a0, R)}A${R} ${R} 0 ${big} 1 ${p(a1, R)}L${p(a1, R - T)}`
      + `A${R - T} ${R - T} 0 ${big} 0 ${p(a0, R - T)}Z" fill="${palette[colour]}" `
      + `stroke="${palette.paper}" stroke-width="0.3"/>`;
    const ky = 8 + i * 7.5;
    key += `<rect x="50" y="${ky - 2.6}" width="3" height="3" fill="${palette[colour]}"/>`
      + `<text x="55" y="${ky}" font-size="2.9" fill="${palette.ink}">${share.toFixed(1)}%</text>`
      + `<text x="55" y="${ky + 3.4}" font-size="2.5" fill="${palette.muted}">${label}</text>`;
    a0 = a1;
  });
  const available = DATA.hours.slice(0, 2).reduce((sum, [, share]) => sum + share, 0); // 94.8
  const middle = `<text x="${CX}" y="${CY + 1.2}" font-size="4" text-anchor="middle" `
    + `fill="${palette.ink}">${available.toFixed(1)}%</text><text x="${CX}" y="${CY + 5}" `
    + `font-size="2.2" text-anchor="middle" fill="${palette.muted}">available</text>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${face}${ring}${middle}${key}</svg>`;
}
// The chair's signature: her name in a script face, and the stroke she draws under it.
function signatureArt(face) {
  return '<svg xmlns="http://www.w3.org/2000/svg" width="420" height="150" viewBox="0 0 42 15">'
    + `${face}<text x="1" y="10" font-size="10" fill="${palette.band}">Maren Coles</text>`
    + '<path d="M3 13.2C14 12.1 27 12.6 40 11.3" fill="none" '
    + `stroke="${palette.band}" stroke-width="0.35" stroke-linecap="round"/></svg>`;
}
async function drawArt() {
  const [face, hand] = await Promise.all([inlineFace('Spline Sans Mono', 500),
    inlineFace('Mrs Saint Delafield', 400)]);
  await Promise.all([loadSvg('ribbons.svg', ribbonsArt(face)), loadSvg('monthly.svg',
    monthlyArt(face)), loadSvg('hours.svg', hoursArt(face)), loadSvg('signature.svg',
    signatureArt(hand))]);
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  'Brygada 1918': ['400', '400i', '700'], // 700: the list dashes
  Epilogue: ['400', '700', '800'], // 700: the crossheads; 800: the display
  'Spline Sans Mono': ['400', '400i', '500', '700'], 'Mrs Saint Delafield': ['400'] }; // signature

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await drawArt();
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Annual report with flush columns',
  es: 'Memoria anual con columnas a ras' }) });

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

## アレンジ

### 見出しにすき間をすべて渡す

3ページ目の2行の空きは、2行とも*The Saltings and the roofs*の上に入り、リストの後には入りません。

```diff
-const balancing = { maxLinesPerHeading: 1 }; // one line per heading; the next lever takes more
+const balancing = {}; // the default: up to 4 lines above each heading
```

### 段末そろえを切る

3ページ目の右段は2行短く終わります。5ページ目では左段が12行、右段が9行になり、計算書は1行下から始まります。

```diff
-const balancing = { maxLinesPerHeading: 1 }; // one line per heading; the next lever takes more
+const balancing = { enabled: false };
```

## よくあるつまずき

- **avoidWidowsは段の末尾を、avoidOrphansは段の頭を守る.** Postextは2種類の孤立行に独自の名前を付けています。avoidWidows（widowMinLines、widowPenalty）は段落の1行目が段の末尾に1行だけ残るのを防ぎ、avoidOrphans（orphanMinLines、orphanPenalty）は段落の最終行が次の段の頭に1行だけ残るのを防ぎます。多くの組版の手引きはこの2つの名前を逆に使っているので、設定は効く位置で選んでください。どちらも既定で有効で、ペナルティとして働きます。レイアウトは、それぞれを、従った場合に生じる空行と比べて判断します。
- **結合セルにはhiddenByのプレースホルダーが要る。mergeCellsを使う.** セルは行の配列内の位置で配置されるため、結合セルが広がる位置にはhiddenByを付けたプレースホルダーのセルが必要です。HTMLのように省くと、それ以降の列がすべてずれます。結合はmergeCellsで作ってください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 最終ページの段の差が1行なら、組版職人が組むのと同じく、左段を1行長くしたまま手を加えません。末尾の切りそろえが働くのは差が2行以上のときだけです。両段を同時に終わらせたいなら、原稿を1行分長くするか短くしてください。
- Postext 1.4.1は表のセルの末尾の空白を、ノーブレークスペースも含めて削ります。正の数値を括弧付きの費用と縦にそろえるため、CodePenのサンプルでは正の数値の末尾にノーブレークスペースとワードジョイナー（U+2060）を置いています。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Brygada 1918 (OFL-1.1), Epilogue (OFL-1.1), Spline Sans Mono (OFL-1.1), Mrs Saint Delafield (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. 013 · ベースライングリッドに組む本のページ](https://postext.dev/ja/cookbook/baseline-grid-book-page.md): 見開きで左右対称の余白をとった2段組みのページです。版面の高さを行送りちょうど44行分にとり、本文が段間とのどをまたいで1つのグリッドに並びます。 · 難易度 2 (中級) · すべてのジャンル
- [No. 010 · データシート：データから作る表とセル結合](https://postext.dev/ja/cookbook/technical-datasheet.md): TSVで貼り付けた表をparseTSVで読み、mergeCells、setAlignment、setCellBackgroundで整えます。レジスターマップは自動でページをまたいで分割されます。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
