# Rapport annuel aux colonnes alignées en pied

> Le rapport annuel d’une coopérative d’énergie sur deux colonnes justifiées qui finissent sur la même ligne, un encadré de chiffres clés et une fin nivelée.

- Version HTML: https://postext.dev/fr/cookbook/annual-report-flush-columns
- Recette Nº 037 · Publications complètes · Niveau 3 (Avancé) · Sorties: Canvas
- Genres: Rapports
- Nécessite postext ≥ 1.28.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/annual-report-flush-columns/en/p01.webp?v=e6565844), [2](https://postext.dev/cookbook/annual-report-flush-columns/en/p02.webp?v=e6565844), [3](https://postext.dev/cookbook/annual-report-flush-columns/en/p03.webp?v=e6565844), [4](https://postext.dev/cookbook/annual-report-flush-columns/en/p04.webp?v=e6565844), [5](https://postext.dev/cookbook/annual-report-flush-columns/en/p05.webp?v=e6565844)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=annual-report-flush-columns&lang=en (.postext: https://postext.dev/cookbook/annual-report-flush-columns/en/annual-report-flush-columns.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [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), [pt](https://postext.dev/pt/cookbook/annual-report-flush-columns.md), [zh](https://postext.dev/zh/cookbook/annual-report-flush-columns.md), [ja](https://postext.dev/ja/cookbook/annual-report-flush-columns.md), [ar](https://postext.dev/ar/cookbook/annual-report-flush-columns.md)

## En bref

Le rapport annuel d’un petit groupe local d’énergie, composé sur deux colonnes. Il montre comment faire finir les deux colonnes de texte exactement à la même hauteur.

## Ce que vous allez composer

Le rapport annuel 2025 de Tidewell Community Energy, une coopérative inventée qui possède deux éoliennes, un champ solaire et des panneaux sur 21 toits. Cinq pages de 210 × 280 mm. Sur la couverture, la production éolienne et solaire de l’année court en deux rubans sous un « 2025 » de 150 pt. La lettre du président, le rapport d’activité et les comptes du trésorier suivent sur deux colonnes justifiées en Brygada 1918, sur une grille de 50 lignes. Les colonnes pleines finissent sur la dernière ligne de la grille, et les courtes au même niveau que leurs voisines. Un encadré de trois chiffres clés traverse la page du président, la lettre nivelée au-dessus, et une note sur le comptage flotte en tête de la page 4. En dernière page, les deux colonnes s’arrêtent sur une même ligne au-dessus du compte de résultat. Un attribut de palette passe les pages d’activité en sarcelle et la page financière en rouge brique.

**Cette recette répond à:**

- Comment faire finir chaque colonne d’un rapport sur la dernière ligne de grille, et nivelée en dernière page ?
- Comment composer un encadré sur les deux colonnes au milieu de la page, comme un panneau « en chiffres » à trois volets ?
- Comment faire flotter un encadré en haut ou en bas de la page pendant que le texte continue de s’écouler ?
- Comment faire un tableau avec rangées d’en-tête, cellules fusionnées, largeurs de colonne et alignement par cellule ?
- Comment diviser un livre en parties ou en sections, chacune avec sa couleur et sa page de partie ?

## La réponse courte

```js
// script.js, lignes 41–58
// 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; a picture with a safe area grows; lines above
// the headings, two each at most (maxLinesBeforeLoose); one line where a list ends; one under
// a display formula or a box, then under a float at the column head; a paragraph set one line
// longer and looser, with up to maxTracking of tracking; last, the lines a heading may still
// take, up to maxLinesPerHeading. 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
```

## Ingrédients

**Enseigne**

- [Équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes): Fait finir chaque colonne au ras du pied de page en ajustant, dans cet ordre, l’espace autour des titres, des listes et des flottants, puis le relâchement des paragraphes et l’approche ; il égalise aussi la dernière page d’un chapitre.

**Utilise aussi**

- [Encadrés sur toute la largeur](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Colonnes dans un encadré ou dans le texte](https://postext.dev/fr/docs/document-format.md#columns)
- [Encadrés flottants](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Couleurs par partie](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-part)
- [Veuves, orphelines et fins de paragraphe trop courtes](https://postext.dev/fr/docs/configuration-text.md#orphelines-veuves-lignes-creuses-et-règles-de-maintien)
- [Coupure optimale des lignes (Knuth–Plass)](https://postext.dev/fr/docs/justification.md#knuth-plass-voir-le-paragraphe-entier)
- [Placement des figures](https://postext.dev/fr/docs/document-format.md#placement)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Tableaux à partir de données](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Styles de tableau nommés](https://postext.dev/fr/docs/configuration-resources.md#styles-de-tableau-nommés)
- [Style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)
- [Fonds de cellule](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Pages de partie](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-part)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`parts`](https://postext.dev/fr/docs/configuration-styles.md#parties), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau), [`tableStyles`](https://postext.dev/fr/docs/configuration-resources.md#styles-de-tableau-nommés), [`unorderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`inlineSvgFonts`](https://postext.dev/fr/docs/configuration-resources.md#polices-du-texte-svg), [`mergeCells`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

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

## Préparation

### 1 · Gardez chaque ligne sur la grille et laissez les leviers combler les vides

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/annual-report-flush-columns.md#la-réponse-courte) plus haut. Le bloc de texte mesure exactement 50 lignes de 13,4 pt de haut, et la marge inférieure (21,6 mm) se déduit de cette hauteur. Un intertitre prend une ligne de grille au-dessus de lui et une pour lui-même : une colonne qui finit court est donc courte de lignes entières, et ce sont des lignes entières qu’ajoute l’[équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes). En [page 3](https://postext.dev/cookbook/annual-report-flush-columns/en/p03.webp?v=e6565844), le paragraphe de trois lignes sur la grêle passe en entier à la page 4 sous l’effet d’`avoidOrphans`, le nom que donne Postext à la règle qui empêche la dernière ligne d’un paragraphe de rester seule en tête de colonne, et la colonne de droite finit deux lignes trop tôt. `maxLinesPerHeading: 1` donne l’une de ces lignes à l’espace au-dessus de *The Saltings and the roofs* et laisse l’autre au levier suivant, qui l’ajoute après la liste des toits. Par défaut, un titre prend jusqu’à deux lignes avant qu’on essaie les leviers qui le suivent (`maxLinesBeforeLoose`), et jusqu’à quatre une fois qu’ils n’ont plus rien à donner : les deux lignes iraient donc au-dessus du titre.

### 2 · Nivelez le texte au-dessus d’un encadré qui traverse la page

```js
// script.js, lignes 101–109
// 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 };
```

Un encadré avec `span="page"` découpe une page à deux colonnes en bandes. En [page 2](https://postext.dev/cookbook/annual-report-flush-columns/en/p02.webp?v=e6565844), la lettre du président s’arrête à huit lignes dans chaque colonne au-dessus de l’encadré et reprend dans les deux colonnes en dessous. Dans l’encadré, `:::columns{count=3 breaks="3,5"}` ouvre la deuxième et la troisième colonne au deuxième et au troisième chiffre (les troisième et cinquième blocs du groupe) au lieu de couper la suite là où les colonnes s’équilibrent le mieux. Le style de paragraphe `figure` compose ces chiffres en 26 pt, et son `boldColor` les imprime en jaune.

### 3 · Faites flotter un encadré en tête de la page suivante

```js
// script.js, lignes 113–122
// 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 } };
```

Dans le Markdown, la note sur le comptage suit le premier paragraphe sur Harrow Down, en page 3 ; `placement="top"` la sort du flux et la place en tête de la page suivante. Le rapport remplit le reste de la page 3, et la [page 4](https://postext.dev/cookbook/annual-report-flush-columns/en/p04.webp?v=e6565844) s’ouvre sur l’encadré qui traverse les deux colonnes. Dessous, le tableau des sites et le graphique en anneau, tous deux en `position: 'top'`, coiffent chacun une colonne, une ligne de grille plus bas que sans les 3,5 mm de remplissage inférieur de l’encadré.

### 4 · Donnez sa couleur à chaque section sans page de séparation

```js
// script.js, lignes 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',
};
// Every colour is linked to its palette entry, so a :::part recolours it 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' } }));
```

Avec `parts: { page: false }`, une clôture comme `:::part{number="02" title="Operations" palette="band=#1c6e67"}` n’ouvre aucune page. Elle nomme la section pour la bande d’ouverture et les pieds courants, et change `band` sur chaque page jusqu’à la partie suivante. Les bandes, les intertitres, les puces, les étiquettes de légende et le filet de l’encadré prennent tous `band` : les pages d’activité s’impriment en sarcelle et la page financière en rouge brique, tandis que la lettre du président garde le bleu mer de base ([couleurs par partie](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-part)).

### 5 · Construisez les tableaux à partir des données, et nivelez la dernière page

```js
// script.js, lignes 170–219
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 in a monospaced face, so the digits line up.
const pad = '\u00a0';
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()` transforme `DATA.accounts` en compte de résultat, ligne par ligne. Un intitulé qui commence par `=` imprime en gras, sur la teinte, la somme cumulée de chaque année ; les charges sont négatives dans les données, si bien que *Operating surplus* sort de cette somme sans aucun total saisi à la main. Un intitulé sans chiffres, *Income* ou *Operating costs*, devient une cellule en italique sur toute la largeur du tableau, fusionnée avec `mergeCells`, qui écrit aussi les cellules de remplacement dont une fusion a besoin. En [page 5](https://postext.dev/cookbook/annual-report-flush-columns/en/p05.webp?v=e6565844), le compte flotte au pied de la page, sur toute la largeur, sous le rapport du trésorier, qui clôt le document. Sans équilibrage, les colonnes feraient 12 lignes à gauche et 9 à droite ; la coupe finale les règle toutes deux à 11, et le levier des titres ajoute au-dessus de *Cash and reserves* la ligne qui manque encore à la colonne de droite.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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.28.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, mergeCells, inlineSvgFonts,
} 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',
};
// Every colour is linked to its palette entry, so a :::part recolours it 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; a picture with a safe area grows; lines above
// the headings, two each at most (maxLinesBeforeLoose); one line where a list ends; one under
// a display formula or a box, then under a float at the column head; a paragraph set one line
// longer and looser, with up to maxTracking of tracking; last, the lines a heading may still
// take, up to maxLinesPerHeading. 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.
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 = () => ({
  locale: 'en-us', colorPalette, resourceTypes, // the report is written in English
  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), inlineResourceGap: 'above' }, // no air under the signature
  // 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: [
    // 'any': a section opens on the next page, recto or verso, with no blank before it.
    { 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 in a monospaced face, so the digits line up.
const pad = '\u00a0';
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);
// The faces of the drawings' labels, named on the root element: loadSvg embeds them.
const LABELS = 'font-family="Spline Sans Mono" font-weight="500"';
const HAND = 'font-family="Mrs Saint Delafield"';
// 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() {
  await Promise.all([loadSvg('ribbons.svg', ribbonsArt(LABELS)), loadSvg('monthly.svg',
    monthlyArt(LABELS)), loadSvg('hours.svg', hoursArt(LABELS)), loadSvg('signature.svg',
    signatureArt(HAND))]);
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  '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 drawArt();
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

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

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

## Variantes

### Laissez les titres prendre tout le vide

Les deux lignes libres de la page 3 vont alors au-dessus de *The Saltings and the roofs*, et aucune après la liste.

```diff
-const balancing = { maxLinesPerHeading: 1 }; // one line per heading; the next lever takes more
+const balancing = {}; // the default: two lines above a heading, then the other levers
```

### Désactivez l’équilibrage

La colonne de droite de la page 3 finit alors deux lignes trop tôt. En page 5, la colonne de gauche fait 12 lignes et celle de droite 9, et le compte commence une ligne plus bas.

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

## Erreurs fréquentes

- **avoidWidows protège le pied d’une colonne, avoidOrphans sa tête.** Postext nomme à sa façon les deux lignes isolées : avoidWidows (widowMinLines, widowPenalty) empêche la première ligne d’un paragraphe de rester seule au pied d’une colonne, et avoidOrphans (orphanMinLines, orphanPenalty) empêche sa dernière ligne de rester seule en tête de la suivante. Beaucoup de manuels de style donnent les deux noms dans l’autre sens : choisissez donc le réglage d’après l’endroit où il agit. Les deux sont activés par défaut, sauf sur une grille de caractères (cjk.grid) : là, avoidWidows est désactivé, et avoidOrphans aussi dans la région Japon, afin qu’une page contienne les lignes que compte sa grille (depuis postext 1.28). Ils fonctionnent comme des pénalités : la mise en page met chacun en balance avec les lignes vides que le respecter laisserait.
- **Les cellules fusionnées demandent des cellules hiddenBy : utilisez mergeCells.** Les cellules sont placées selon leur position dans le tableau de la ligne : une cellule fusionnée demande donc des cellules de remplacement marquées hiddenBy là où elle s’étend ; les omettre, comme le fait le HTML, décale toutes les colonnes suivantes. Construisez les fusions avec mergeCells. Depuis postext 1.5, un tableau dont la grille est irrégulière est signalé dans doc.contentWarnings comme raggedTableGrid, et parseTSV(text, { headerRows }) marque les lignes d’en-tête.

- Quand les colonnes d’une dernière page diffèrent d’une ligne, elles restent telles quelles, la colonne de gauche plus longue d’une ligne, comme les composerait un typographe ; la coupe finale n’agit que sur un écart de deux lignes ou plus. Pour faire finir les deux ensemble, allongez ou raccourcissez le texte d’une ligne.
- Le compte de résultat met les charges entre parenthèses. Pour aligner les chiffres positifs sur elles, le pen termine chaque chiffre positif par une espace insécable, qui, dans un caractère à chasse fixe, est aussi large que la parenthèse fermante ; une cellule de tableau garde cette espace en fin de texte.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: Brygada 1918 (OFL-1.1), Epilogue (OFL-1.1), Spline Sans Mono (OFL-1.1), Mrs Saint Delafield (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 027 · La une d’un journal](https://postext.dev/fr/cookbook/newspaper-front-page.md): La une et la page 2 d’un hebdomadaire local en deux colonnes à filet, gros titres en encadrés pleine page et bandeau de brèves sur quatre colonnes en pied. · Niveau 3 (Avancé) · Journaux et lettres d’information
- [Nº 013 · Une page de livre sur une grille de ligne de base](https://postext.dev/fr/cookbook/baseline-grid-book-page.md): Une page en miroir sur deux colonnes dont le bloc de texte mesure 44 lignes pile : le texte courant tient sur une seule grille, d’une colonne à l’autre. · Niveau 2 (Intermédiaire) · Tous genres
- [Nº 010 · Fiche technique : tableaux de données, en-têtes fusionnés](https://postext.dev/fr/cookbook/technical-datasheet.md): Des tableaux collés en TSV, lus par parseTSV, façonnés par mergeCells, setAlignment et setCellBackground ; une carte de registres qui se coupe seule. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
