# Garden almanac: calendar grid and landscape chart

> A 38-crop sowing chart set in landscape on two pages of its own, a calendar computed from dates and a companion matrix, their cells filled from the palette.

- HTML version: https://postext.dev/en/cookbook/garden-almanac
- Recipe Nº 035 · Tables · Level 3 (Advanced) · Outputs: Canvas
- Genres: Manuals, guides & reference
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [27](https://postext.dev/cookbook/garden-almanac/en/p01.webp?v=91c9926f), [28](https://postext.dev/cookbook/garden-almanac/en/p02.webp?v=91c9926f), [29](https://postext.dev/cookbook/garden-almanac/en/p03.webp?v=91c9926f), [30](https://postext.dev/cookbook/garden-almanac/en/p04.webp?v=91c9926f)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/garden-almanac.md)

## What you'll build

The March pages of a gardener's almanac written in Italian. Page 27 opens on a drawing of seedlings in dark soil under a pale sky, with the month's name at 84 pt and a country proverb. Under two columns of text comes the month's calendar, which the pen computes from dates: Sundays in red on a cream column, Easter Sunday and Monday on pink, and the moon's four quarters drawn beside their days. Page 28 lists the month's work in a two-column box and colours a ten-by-ten companion-planting matrix green, pink or cream, with a drawing of each crop on the diagonal. The year's sowing dates, 38 crops by fortnight, are wider than the page, so the chart turns a quarter onto pages 29 and 30 and repeats its head on the second.

**This recipe answers:**

- How do I set a wide table in landscape on pages of its own, with its head repeated where it continues?
- How do I make a table with header rows, merged cells, column widths and per-cell alignment?
- How do I style several tables differently (fills, zebra cells, rounded frames) in one document?
- How do I put pictures or icons inside table cells?
- How do I add colour-key swatches to text, captions or table notes?

## The short answer

A crops × fortnights chart, turned to landscape on pages of its own.

```js
// script.js, lines 30–66
// The chart's `placement` (#region resources): a quarter turn makes it a page-span float on
// pages of its own, flush to the spine; rows past the page's width go on under a repeated head.
const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const SEASONS = [['INVERNO', 2], ['PRIMAVERA', 3], ['ESTATE', 3], ['AUTUNNO', 3], ['INVERNO', 1]];
const STATES = { S: 'ochre', C: 'green', T: 'brown' }; // seedbed, sown outdoors, planted out
const fortnight = (key) => MONTHS.indexOf(key.slice(0, 3)) * 2 + Number(key[3]); // 'mar2' → 6
function sowingChart(data) { // 'Pomodoro: S feb2–mar2, T apr2–mag2'; a bare line is a family
  const row = (first, isHeader = false) => [first, ...Array(24).fill('')]
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 2, columnWidths: [30, ...Array(24).fill(8.5)], // mm, as weights
    rows: [row('', true), row('', true)] };
  for (const line of data.trim().split('\n')) {
    const [name, plan] = line.split(': ');
    const r = m.rows.push(row(plan ? name : chip(name.toUpperCase(), 'famiglia'))) - 1;
    if (!plan) { m = mergeCells(m, span(r, 0, r, 24)); continue; } // a family heads its crops
    for (let f = 1; f <= 24; f++) { // every other month tinted, so a column reads down the page
      if ((f - 1) % 4 < 2) m = setCellBackground(m, at(r, f), col('cream'));
    }
    for (const step of plan.split(', ')) { // 'S feb2–mar2': one state over a run of fortnights
      const [state, range] = step.split(' ');
      const [from, to = from] = range.split('–').map(fortnight);
      for (let f = from; f <= to; f++) m = setCellBackground(m, at(r, f), col(STATES[state]));
    }
  }
  // Merged heads: 'Coltura' down both rows, each season over its months, each month over its
  // two fortnights. mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby).
  const merge = (r0, c0, r1, c1, content) => { // the head's text goes in its first cell
    m = mergeCells(setCellContent(m, at(r0, c0), content), span(r0, c0, r1, c1));
  };
  merge(0, 0, 1, 0, 'Coltura');
  let c = 1;
  for (const [name, n] of SEASONS) { merge(0, c, 0, c + 2 * n - 1, name); c += 2 * n; }
  MONTHS.forEach((month, i) => merge(1, 2 * i + 1, 1, 2 * i + 2, month.toUpperCase()));
  for (const r of [0, 1]) for (let f = 1; f <= 24; f++) m = setAlignment(m, at(r, f), 'center');
  return setCellBackground(m, at(1, fortnight('mar1')), col('red')); // this month's head
}
```

## Ingredients

**Teaches**

- [Landscape tables and figures](https://postext.dev/en/docs/document-format.md#placement): A wide table or figure turned a quarter on a page of its own, flush to the spine; a table that does not fit continues on the next page, broken between rows.
- [Tables from data](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement): Table resources with header rows, merged cells, column proportions, per-cell alignment and lists inside cells; pipe tables are not parsed.
- [Cell fills](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement): A palette-linked background per cell, for heat maps, compatibility matrices and zebra rows set by hand.

**Also uses**

- [Tables across pages](https://postext.dev/en/docs/configuration.md#tables-taller-than-the-page)
- [Named table styles](https://postext.dev/en/docs/configuration.md#named-table-styles)
- [Table style](https://postext.dev/en/docs/configuration.md#table-style)
- [Pictures in table cells](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Colour swatches](https://postext.dev/en/docs/document-format.md#inline-formatting)
- [Inline chips](https://postext.dev/en/docs/configuration.md#chip-styles)
- [Columns inside a box](https://postext.dev/en/docs/document-format.md#columns)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Source and credit lines](https://postext.dev/en/docs/configuration.md#caption-style)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Hyphenation and document language](https://postext.dev/en/docs/justification.md#supported-locales)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Nested boxes](https://postext.dev/en/docs/configuration.md#the-callout-container)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Roman front matter](https://postext.dev/en/docs/document-format.md#numbering)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`chipStyles`](https://postext.dev/en/docs/configuration.md#chip-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style), [`tableStyles`](https://postext.dev/en/docs/configuration.md#named-table-styles), [`unorderedLists`](https://postext.dev/en/docs/configuration.md#unordered-lists)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`mergeCells`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`parseTSV`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap), [`setAlignment`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`setCellBackground`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), `setCellContent`, [`setCellImage`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)

**Typefaces**

- Piazzolla (OFL-1.1), Gilda Display (OFL-1.1), Commissioner (OFL-1.1)

## Method

### 1 · Turn the chart and let it run on

The code is [the short answer](#the-short-answer) above. `rotate: 'ccw'` makes the table a page-span float on pages of its own ([Placement](/en/docs/document-format#placement)), with its head towards the left edge of the page, and it sits against the spine on both the recto and the verso. It is laid out along the height of the text area, 227 of its 237 mm: the 47 whole 14 pt grid lines that fit, less one line for the float gap. Its rows stack across the 176 mm width, and when they outrun it the table is cut between two rows and goes on over the page under its two head rows and its caption, which ends in `(segue)` ([Tables taller than the page](/en/docs/configuration#tables-taller-than-the-page)). Only a float can turn or split: at `position: 'here'` the rotation is ignored and the table never splits. The head is merged three ways: `Coltura` down both head rows, each season over its months, and each month over its two fortnights.

### 2 · A calendar computed from dates

```js
// script.js, lines 70–109
const [YEAR, MONTH, DAY] = [2027, 3, 24 * 60 * 60 * 1000]; // DAY in ms
const epochDay = (m, d) => Date.UTC(YEAR, m - 1, d) / DAY; // 1 January 1970 was a Thursday
const weekday = (d) => (epochDay(MONTH, d) + 3) % 7; // 0 is Monday: Italian weeks start there
function easter(y) { // the Gregorian computus (Meeus): [month, day]
  const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4);
  const g = Math.floor((8 * b + 13) / 25), h = (19 * a + b - d - g + 15) % 30;
  const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * (b % 4) + 2 * i - h - k) % 7;
  const n = h + l - 7 * Math.floor((a + 11 * h + 22 * l) / 451) + 114;
  return [Math.floor(n / 31), (n % 31) + 1];
}
// The moon's age: mean synodic months since the new moon of 6 January 2000 at 18.14 UT.
const [SYNODIC, NEW_MOON] = [29.530588853, Date.UTC(2000, 0, 6, 18, 14) / DAY];
const quarter = (day) => Math.floor((((day - NEW_MOON) % SYNODIC) / SYNODIC) * 4); // 0–3
const PHASES = ['luna-nuova', 'primo-quarto', 'luna-piena', 'ultimo-quarto'];
const JOINER = '\u2060'; // a word joiner: the second line of a day without a note
const DAYS = ['LUNEDÌ', 'MARTEDÌ', 'MERCOLEDÌ', 'GIOVEDÌ', 'VENERDÌ', 'SABATO', 'DOMENICA'];
function calendarTable() { // the grid, and the quarters it draws, listed for the caption
  const e = epochDay(...easter(YEAR)) - epochDay(MONTH, 0); // Easter as a day of MONTH: 28 in 2027
  const feasts = { [e - 7]: 'Le Palme', [e]: 'Pasqua', [e + 1]: 'Pasquetta' };
  const notes = { 19: 'S. Giuseppe', 20: 'Equinozio' }; // March 2027's, typed by hand
  const first = weekday(1), days = epochDay(MONTH + 1, 1) - epochDay(MONTH, 1), moons = [];
  const week = (names, isHeader) => names.flatMap((content) => [content, '']) // a day: moon, date
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 1, columnWidths: Array(7).fill([7, 18]).flat(), rows: [week(DAYS, true),
    ...Array.from({ length: Math.ceil((first + days) / 7) }, () => week(Array(7).fill('')))] };
  for (let d = 1; d <= days; d++) {
    const r = 1 + Math.floor((first + d - 1) / 7), c = 2 * weekday(d), sunday = c === 12;
    // Sundays and feasts print in red. Every day has a second line, so all weeks are as tall.
    const note = feasts[d] ? chip(feasts[d], 'festa') : notes[d] ? chip(notes[d], 'nota') : JOINER;
    m = setCellContent(m, at(r, c + 1), `${sunday || feasts[d] ? chip(d, 'rosso') : d}\n${note}`);
    const fill = d === e || d === e + 1 ? 'blush' : sunday ? 'cream' : null;
    if (fill) for (const k of [c, c + 1]) m = setCellBackground(m, at(r, k), col(fill));
    const midnight = epochDay(MONTH, d) - 1 / 24, q = quarter(midnight + 1); // 00.00 CET
    if (q === quarter(midnight)) continue;
    m = setCellImage(m, at(r, c), { resourceId: PHASES[q] }); // a quarter begins today
    moons.push(`${PHASES[q].replace('-', ' ')} ${[1, 8, 11].includes(d) ? 'l’' : 'il '}${d}`);
  }
  for (let i = 0; i < 7; i++) m = mergeCells(m, span(0, 2 * i, 0, 2 * i + 1)); // one head a day
  return { model: setCellBackground(m, at(0, 12), col('red')), moons: moons.join(', ') };
}
```

Day numbers from `Date.UTC` give every date its weekday, and the grid starts on Monday 1 March and runs to five weeks. Easter comes from the computus and the moon's quarters from the mean synodic month. Computed that way, a quarter can fall a day early or late, as the caption warns. The loop that draws each moon also writes its date into the caption. A cell's picture always sits above its text, which is why each day takes two columns, the moon's and the date's: a moon in the date's own cell would push the date down on four days only. Sundays and feasts are red through a chip style that changes only the colour, and the notes are chips at half the size. A day without a note gets a word joiner (U+2060) as its second line, which keeps the five weeks the same height.

### 3 · Colour the matrix from pasted symbols

```js
// script.js, lines 113–129
const FILLS = { '+': 'leaf', '−': 'blush', '': 'cream' }; // good, bad, no known effect
function companionTable(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [26, ...Array(10).fill(15)] };
  for (let r = 1; r < m.rows.length; r++) {
    m = setAlignment(m, at(r, 0), 'left', 'middle');
    for (let c = 1; c < m.rows[r].length; c++) {
      const symbol = m.rows[r][c].content; // + and − stay printed, for greyscale copies
      m = setCellContent(m, at(r, c), symbol && symbol !== '=' ? chip(symbol, 'segno') : '');
      m = symbol === '=' // the diagonal pairs a crop with itself: its picture instead
        ? setCellImage(m, at(r, c), { resourceId: `veg-${r}`, width: 0.62 })
        : setCellBackground(m, at(r, c), col(FILLS[symbol]));
      m = setAlignment(m, at(r, c), 'center', 'middle');
    }
  }
  for (let c = 1; c <= 10; c++) m = setAlignment(m, at(0, c), 'center');
  return m;
}
```

The pairs are typed as TSV, one symbol per cell. In postext 1.4.1 `parseTSV` makes plain cells and leaves `headerRowCount` unset, so `headerRowCount: 1` is added by hand to make the row of crop names the head. The `columnWidths` weights are millimetres of the 176 mm measure: 26 for the names, 15 for each crop. Each symbol names a palette entry, which `setCellBackground` lays in as the cell's fill; the `+` and `−` are printed as well, in a chip at 1.4 em, so the matrix still reads in a greyscale copy. The diagonal pairs a crop with itself, and there `setCellImage` puts the crop's drawing, at 62 % of the cell's inner width.

### 4 · Key the colours with swatches

```js
// script.js, lines 427–451
const resourceTypes = [ // 1.4.1 has English and Spanish ones (gotcha: resource-types-locale)
  { id: 'table', name: 'Tabella', shortLabel: 'Tab.', captionPrefix: 'Tabella',
    captionStyle: { position: 'above' } },
  { id: 'calendar', name: 'Calendario', shortLabel: 'Cal.', captionPrefix: '' }, // no label
].map((t) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...t }));
const table = (id, typeId, caption, model, styleId, extra) => ({ id, typeId, kind: 'table',
  caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...extra });
const foot = { position: 'bottom', span: 'page' }; // across both columns, at the page's foot
const calendar = calendarTable(); // its key names the quarters the grid draws
const resources = [...pictures, // never cited: the opener and the cells draw them by id
  table('calendario', 'calendar', ':swatch{color="cream"} domeniche · :swatch{color="blush"} '
    + `Pasqua e Pasquetta · ${calendar.moons}, sul mese sinodico medio: un giorno prima o dopo `
    + 'è possibile. Per tradizione in crescente si semina ciò che fruttifica sopra terra, in '
    + 'calante le radici.', calendar.model, 'calendario', { placement: foot }),
  table('consociazioni', 'table', 'Consociazioni tra dieci ortaggi', companionTable(companions),
    'matrice', { placement: foot, note: ':swatch{color="leaf"} + favorevole · '
      + ':swatch{color="blush"} − da evitare · :swatch{color="cream"} nessun effetto noto. '
      + 'Indicazioni della tradizione orticola.' }),
  // Each part of a split table repeats its caption, so the key goes there; the note ends the last.
  table('semine', 'table', 'Semine al Nord e al Centro, in pianura e collina: '
    + ':swatch{color="ochre"} in semenzaio protetto · :swatch{color="green"} in piena terra · '
    + ':swatch{color="brown"} trapianto o messa a dimora', sowingChart(sowing), 'semine', {
    placement: chartPlacement, note: 'Al Sud e lungo le coste le date si anticipano di '
      + 'due-quattro settimane; in montagna si ritardano.' }),
];
```

`:swatch{color="leaf"}` sets a square three quarters of the type size, filled with a palette entry and outlined in the text colour; these pages use it in running text, in two captions and in a note. The chart's key goes in its caption, because every part of a split table repeats the caption and the note is set under the last part only. The calendar is a resource type of its own with an empty caption prefix, and its caption opens on the key instead of a `Tabella` label. `position: 'bottom'` with `span: 'page'` sets the calendar and the matrix across both columns at the foot of their pages. Page 28 is the last page of text. On such a page 1.4.1 lifts a page-wide float to one line under the text, which is why the matrix ends 10 mm above the bottom margin.

### 5 · A house style and a variant per table

```js
// script.js, lines 133–144
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(7), bodyFontSize: pt(8.5), cellPadding: mm(1.2),
  // 1.4.1 has continuation strings in English and Spanish only (gotcha: resource-types-locale).
  continuedSuffix: '(segue)', continuesMarker: 'Continua alla pagina seguente' };
const tableStyles = [
  { id: 'calendario', bodyFontFamily: DISPLAY, bodyFontSize: pt(15), cellPadding: mm(1.4) },
  { id: 'matrice', rules: 'grid', borderColor: col('paper'), borderWidth: pt(2), // tiles
    headerBackgroundEnabled: false, headerColor: col('ink'), headerFontSize: pt(6.8) },
  { id: 'semine', rules: 'grid', borderColor: col('paper'), borderWidth: pt(1),
    bodyFontSize: pt(7.8), headerFontSize: pt(6.6), cellPadding: mm(1.1) },
];
```

`tableStyle` sets the house style: an ink head with paper-coloured labels in Commissioner, hairlines between the rows, and the Italian continuation strings, which 1.4.1 ships in English and Spanish only. Each named style states only what its table changes. The calendar sets its days in Gilda Display at 15 pt; the matrix drops the head's fill; the matrix and the chart draw grid rules in the paper's colour, 2 pt and 1 pt wide, which cut their fills into tiles.

### 6 · The month opens on a drawing

```js
// script.js, lines 148–169
const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: drawing, air under it, side bearing of the 84 pt M
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) }); // to: 'page', or '#id' of an element listed before
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left',
  overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// The drawing reserves nothing (gotcha: opener-image-no-reserve), so the text starts on the
// first grid line at least AIR under it.
const OPENER_H = pt(LEAD * Math.ceil((ART_H + AIR - PAGE.top) / (LEAD * 25.4 / 72)));
const opener = { enabled: true, minHeight: OPENER_H, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'campo',
    placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(ART_H) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, 'red', pin('page', 'top-left', PAGE.inner, 12),
    caps(8.5)), // page 1 is a recto: its inner margin is on the left
  text('title', '{titleText}', DISPLAY, 84, 'ink', pin('#kicker', 'below', -BEARING, 1),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('proverb', '{attr.proverb}', TEXT, 12.5, 'ink',
    pin('#title', 'below', BEARING, 1, { width: mm(140) }), { italic: true, lineHeight: 1.3 }),
  text('source', '{attr.source}', LABEL, 7, 'ink', pin('#proverb', 'below', 0, 1.6), caps(7)),
] } };
```

The first-level heading is drawn from a slot of elements. The drawing is an image element at the head of the page, and the kicker, the proverb and its source come from the heading's attributes. An image element reserves no height in 1.4.1, and `minHeight` makes up for it: 92 mm of drawing plus 5 mm of air, less the 22 mm top margin, is 75 mm, which rounds up to 16 lines of 14 pt (79 mm). The first line of text sits 101 mm from the top of the page, 9 mm under the soil. The title moves 1.5 mm left, the side bearing of Gilda Display's M at 84 pt, and the M's serif lines up with the kicker; the proverb moves back by the same amount.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/garden-almanac

### script.js

```js
// ═══ Postext Cookbook · Nº 035 · Garden almanac: calendar grid and landscape chart ═════
// https://postext.dev/en/cookbook/garden-almanac
// Code: MIT · Text: original, in Italian (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV,
  mergeCells, setCellContent, setCellBackground, setCellImage, setAlignment,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#262a22', paper: '#fbf8ef', // a green-black on unbleached paper
  red: '#a2372a', // Sundays and feasts, as almanacs print them; kickers and labels
  green: '#5b8a32', ochre: '#d39a2e', brown: '#7a5230', // sown outdoors, in a seedbed, planted
  leaf: '#bfdaa2', blush: '#f3cdbd', cream: '#e9ddc1', // good pairs, bad pairs, neutral pairs
  sky: '#cfe2e6', rule: '#cfc6b2', muted: '#6b6e63' }; // sky and work box; hairlines; notes
// 1.4.1 design slots read the hex, not the id: col() writes both (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Piazzolla', 'Gilda Display', 'Commissioner'];
const PAGE = { w: 210, h: 280, top: 22, bottom: 21, inner: 18, outer: 16 }; // mm, mirrored
const LEAD = 14; // pt: the body's leading and baseline grid
const at = (row, column) => ({ row, col: column });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const chip = (text, style) => `:chip[${text}]{style="${style}"}`;

// #region answer: a crops × fortnights chart, turned to landscape on pages of its own
// The chart's `placement` (#region resources): a quarter turn makes it a page-span float on
// pages of its own, flush to the spine; rows past the page's width go on under a repeated head.
const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const SEASONS = [['INVERNO', 2], ['PRIMAVERA', 3], ['ESTATE', 3], ['AUTUNNO', 3], ['INVERNO', 1]];
const STATES = { S: 'ochre', C: 'green', T: 'brown' }; // seedbed, sown outdoors, planted out
const fortnight = (key) => MONTHS.indexOf(key.slice(0, 3)) * 2 + Number(key[3]); // 'mar2' → 6
function sowingChart(data) { // 'Pomodoro: S feb2–mar2, T apr2–mag2'; a bare line is a family
  const row = (first, isHeader = false) => [first, ...Array(24).fill('')]
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 2, columnWidths: [30, ...Array(24).fill(8.5)], // mm, as weights
    rows: [row('', true), row('', true)] };
  for (const line of data.trim().split('\n')) {
    const [name, plan] = line.split(': ');
    const r = m.rows.push(row(plan ? name : chip(name.toUpperCase(), 'famiglia'))) - 1;
    if (!plan) { m = mergeCells(m, span(r, 0, r, 24)); continue; } // a family heads its crops
    for (let f = 1; f <= 24; f++) { // every other month tinted, so a column reads down the page
      if ((f - 1) % 4 < 2) m = setCellBackground(m, at(r, f), col('cream'));
    }
    for (const step of plan.split(', ')) { // 'S feb2–mar2': one state over a run of fortnights
      const [state, range] = step.split(' ');
      const [from, to = from] = range.split('–').map(fortnight);
      for (let f = from; f <= to; f++) m = setCellBackground(m, at(r, f), col(STATES[state]));
    }
  }
  // Merged heads: 'Coltura' down both rows, each season over its months, each month over its
  // two fortnights. mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby).
  const merge = (r0, c0, r1, c1, content) => { // the head's text goes in its first cell
    m = mergeCells(setCellContent(m, at(r0, c0), content), span(r0, c0, r1, c1));
  };
  merge(0, 0, 1, 0, 'Coltura');
  let c = 1;
  for (const [name, n] of SEASONS) { merge(0, c, 0, c + 2 * n - 1, name); c += 2 * n; }
  MONTHS.forEach((month, i) => merge(1, 2 * i + 1, 1, 2 * i + 2, month.toUpperCase()));
  for (const r of [0, 1]) for (let f = 1; f <= 24; f++) m = setAlignment(m, at(r, f), 'center');
  return setCellBackground(m, at(1, fortnight('mar1')), col('red')); // this month's head
}
// #endregion

// #region calendar: the month grid from real dates: weekdays, Easter, the moon's quarters
const [YEAR, MONTH, DAY] = [2027, 3, 24 * 60 * 60 * 1000]; // DAY in ms
const epochDay = (m, d) => Date.UTC(YEAR, m - 1, d) / DAY; // 1 January 1970 was a Thursday
const weekday = (d) => (epochDay(MONTH, d) + 3) % 7; // 0 is Monday: Italian weeks start there
function easter(y) { // the Gregorian computus (Meeus): [month, day]
  const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4);
  const g = Math.floor((8 * b + 13) / 25), h = (19 * a + b - d - g + 15) % 30;
  const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * (b % 4) + 2 * i - h - k) % 7;
  const n = h + l - 7 * Math.floor((a + 11 * h + 22 * l) / 451) + 114;
  return [Math.floor(n / 31), (n % 31) + 1];
}
// The moon's age: mean synodic months since the new moon of 6 January 2000 at 18.14 UT.
const [SYNODIC, NEW_MOON] = [29.530588853, Date.UTC(2000, 0, 6, 18, 14) / DAY];
const quarter = (day) => Math.floor((((day - NEW_MOON) % SYNODIC) / SYNODIC) * 4); // 0–3
const PHASES = ['luna-nuova', 'primo-quarto', 'luna-piena', 'ultimo-quarto'];
const JOINER = '\u2060'; // a word joiner: the second line of a day without a note
const DAYS = ['LUNEDÌ', 'MARTEDÌ', 'MERCOLEDÌ', 'GIOVEDÌ', 'VENERDÌ', 'SABATO', 'DOMENICA'];
function calendarTable() { // the grid, and the quarters it draws, listed for the caption
  const e = epochDay(...easter(YEAR)) - epochDay(MONTH, 0); // Easter as a day of MONTH: 28 in 2027
  const feasts = { [e - 7]: 'Le Palme', [e]: 'Pasqua', [e + 1]: 'Pasquetta' };
  const notes = { 19: 'S. Giuseppe', 20: 'Equinozio' }; // March 2027's, typed by hand
  const first = weekday(1), days = epochDay(MONTH + 1, 1) - epochDay(MONTH, 1), moons = [];
  const week = (names, isHeader) => names.flatMap((content) => [content, '']) // a day: moon, date
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 1, columnWidths: Array(7).fill([7, 18]).flat(), rows: [week(DAYS, true),
    ...Array.from({ length: Math.ceil((first + days) / 7) }, () => week(Array(7).fill('')))] };
  for (let d = 1; d <= days; d++) {
    const r = 1 + Math.floor((first + d - 1) / 7), c = 2 * weekday(d), sunday = c === 12;
    // Sundays and feasts print in red. Every day has a second line, so all weeks are as tall.
    const note = feasts[d] ? chip(feasts[d], 'festa') : notes[d] ? chip(notes[d], 'nota') : JOINER;
    m = setCellContent(m, at(r, c + 1), `${sunday || feasts[d] ? chip(d, 'rosso') : d}\n${note}`);
    const fill = d === e || d === e + 1 ? 'blush' : sunday ? 'cream' : null;
    if (fill) for (const k of [c, c + 1]) m = setCellBackground(m, at(r, k), col(fill));
    const midnight = epochDay(MONTH, d) - 1 / 24, q = quarter(midnight + 1); // 00.00 CET
    if (q === quarter(midnight)) continue;
    m = setCellImage(m, at(r, c), { resourceId: PHASES[q] }); // a quarter begins today
    moons.push(`${PHASES[q].replace('-', ' ')} ${[1, 8, 11].includes(d) ? 'l’' : 'il '}${d}`);
  }
  for (let i = 0; i < 7; i++) m = mergeCells(m, span(0, 2 * i, 0, 2 * i + 1)); // one head a day
  return { model: setCellBackground(m, at(0, 12), col('red')), moons: moons.join(', ') };
}
// #endregion

// #region matrix: companion pairs pasted as TSV; each symbol becomes a palette fill
const FILLS = { '+': 'leaf', '−': 'blush', '': 'cream' }; // good, bad, no known effect
function companionTable(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [26, ...Array(10).fill(15)] };
  for (let r = 1; r < m.rows.length; r++) {
    m = setAlignment(m, at(r, 0), 'left', 'middle');
    for (let c = 1; c < m.rows[r].length; c++) {
      const symbol = m.rows[r][c].content; // + and − stay printed, for greyscale copies
      m = setCellContent(m, at(r, c), symbol && symbol !== '=' ? chip(symbol, 'segno') : '');
      m = symbol === '=' // the diagonal pairs a crop with itself: its picture instead
        ? setCellImage(m, at(r, c), { resourceId: `veg-${r}`, width: 0.62 })
        : setCellBackground(m, at(r, c), col(FILLS[symbol]));
      m = setAlignment(m, at(r, c), 'center', 'middle');
    }
  }
  for (let c = 1; c <= 10; c++) m = setAlignment(m, at(0, c), 'center');
  return m;
}
// #endregion

// #region styles: one house table style and a named variant for each of the three tables
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(7), bodyFontSize: pt(8.5), cellPadding: mm(1.2),
  // 1.4.1 has continuation strings in English and Spanish only (gotcha: resource-types-locale).
  continuedSuffix: '(segue)', continuesMarker: 'Continua alla pagina seguente' };
const tableStyles = [
  { id: 'calendario', bodyFontFamily: DISPLAY, bodyFontSize: pt(15), cellPadding: mm(1.4) },
  { id: 'matrice', rules: 'grid', borderColor: col('paper'), borderWidth: pt(2), // tiles
    headerBackgroundEnabled: false, headerColor: col('ink'), headerFontSize: pt(6.8) },
  { id: 'semine', rules: 'grid', borderColor: col('paper'), borderWidth: pt(1),
    bodyFontSize: pt(7.8), headerFontSize: pt(6.6), cellPadding: mm(1.1) },
];
// #endregion

// #region opener: the month on a drawing, with its proverb from the heading's attributes
const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: drawing, air under it, side bearing of the 84 pt M
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) }); // to: 'page', or '#id' of an element listed before
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left',
  overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// The drawing reserves nothing (gotcha: opener-image-no-reserve), so the text starts on the
// first grid line at least AIR under it.
const OPENER_H = pt(LEAD * Math.ceil((ART_H + AIR - PAGE.top) / (LEAD * 25.4 / 72)));
const opener = { enabled: true, minHeight: OPENER_H, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'campo',
    placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(ART_H) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, 'red', pin('page', 'top-left', PAGE.inner, 12),
    caps(8.5)), // page 1 is a recto: its inner margin is on the left
  text('title', '{titleText}', DISPLAY, 84, 'ink', pin('#kicker', 'below', -BEARING, 1),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('proverb', '{attr.proverb}', TEXT, 12.5, 'ink',
    pin('#title', 'below', BEARING, 1, { width: mm(140) }), { italic: true, lineHeight: 1.3 }),
  text('source', '{attr.source}', LABEL, 7, 'ink', pin('#proverb', 'below', 0, 1.6), caps(7)),
] } };
// #endregion

const head = (id, content, parity, x, extra) => text(id, content, LABEL, 7.5, 'muted',
  pin('page', x > 0 ? 'top-left' : 'top-right', x, 12), { ...caps(7.5), parity, pages: 'body',
    ...extra }); // body pages only: the opener has its drawing, and a folio at the foot
const folio = { fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 400, letterSpacing: pt(0),
  color: col('red') };
const header = { elements: [head('verso-folio', '{pageNumber}', 'even', PAGE.outer, folio),
  head('verso-title', '{title}', 'even', PAGE.outer + 10),
  head('recto-title', '{chapterTitle}', 'odd', -(PAGE.outer + 10)),
  head('recto-folio', '{pageNumber}', 'odd', -PAGE.outer, folio)] };
const footer = { elements: [text('drop-folio', '{pageNumber}', DISPLAY, 11, 'red',
  pin('page', 'bottom', 0, -12), { pages: 'opener', align: 'center' })] }; // the opener's folio
const bare = (id, extra) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), ...extra }); // a chip that is only a change of face, size or colour
const note = (color) => ({ fontFamily: TEXT, fontSize: em(0.5), italic: true, color: col(color) });

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: 'it', resourceTypes, colorPalette, tableStyle, tableStyles, header, footer,
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'),
    pageNumbering: { startAt: 27 }, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom),
      left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, // March opens on p. 27
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    boldFontWeight: 600, boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink'), referenceBold: false, firstLineIndent: mm(4),
    indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 }, // from 0.6 and 2
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      advancedDesign: opener, marginBottom: pt(0) },
    { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) },
  ] },
  chipStyles: [bare('rosso', { color: col('red') }), bare('nota', note('muted')),
    bare('festa', note('red')), bare('segno', { fontFamily: LABEL, fontSize: em(1.4), bold: true }),
    bare('famiglia', { fontFamily: LABEL, fontSize: em(0.85), bold: true, color: col('red') })],
  calloutStyles: [
    { id: 'lavori', title: 'Lavori del mese', span: 'page', background: col('sky'),
      padding: { top: mm(3.5), right: mm(5), bottom: mm(4), left: mm(5) }, columnGap: mm(7),
      titleStyle: { fontFamily: LABEL, fontSize: pt(8), ...caps(8), color: col('red') },
      body: { fontSize: pt(9.2), lineHeight: pt(13) } },
    { id: 'colonna', backgroundEnabled: false, padding: { top: mm(0), right: mm(0), // no frame
      bottom: mm(0), left: mm(0) }, lists: { gap: mm(2.2), itemSpacing: pt(2) },
      titleStyle: { fontFamily: TEXT, fontSize: pt(9.2), italic: true, fontWeight: 400,
        color: col('ink') } },
  ],
  unorderedLists: { bulletChar: '–', color: col('red'), fontWeight: 400 },
  captionStyle: { fontSize: pt(8.5), labelColor: col('red'), gap: mm(2),
    note: { fontSize: pt(7.5), color: col('muted') } },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), textAlign: 'left',
    color: col('muted'), firstLineIndent: pt(0), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Almanacco dell’orto 2027"
author: "Redazione dell’Almanacco"
---

# Marzo {kicker="Almanacco dell’orto · 2027" proverb="Marzo asciutto, aprile bagnato, beato il villan che ha seminato." source="Proverbio contadino"}

A marzo l’orto riparte. Il :ref{id="calendario" text="calendario"} in fondo alla pagina segna l’equinozio, sabato 20 alle 21.25, e la domenica di Pasqua, il 28, che quest’anno coincide con il ritorno dell’ora legale. A Bologna il giorno dura 11 ore e 9 minuti il primo del mese e 12 ore e 42 minuti il 31, un’ora e mezza abbondante in più.

Il terreno però resta freddo. Si lavora solo quando una zolla stretta nel pugno si sbriciola invece di impastarsi, e all’aperto si semina soltanto ciò che nasce anche sotto i dieci gradi, come piselli, spinaci, carote, rucola e ravanelli. Pomodori, peperoni e melanzane restano al riparo, perché per germinare vogliono tra i venti e i venticinque gradi. Nelle notti serene la brina è possibile fino all’inizio di aprile: un telo di tessuto non tessuto steso la sera sulle file appena nate le protegge, e si toglie al mattino quando il sole comincia a scaldare.

:::callout{type="lavori"}
:::columns{count=2}
:::callout{type="colonna" title="In semenzaio"}
- Semina pomodori, peperoni e melanzane al caldo, in alveoli vicino alla luce.
- Da metà mese semina zucchine, cetrioli e meloni, due semi per vasetto.
- Semina sedano e basilico; il sedano impiega due o tre settimane a nascere.
:::
:::callout{type="colonna" title="In piena terra"}
- Semina a file piselli, spinaci, ravanelli e carote; la rucola anche a spaglio.
- Metti a dimora patate, bulbilli di cipolla e scalogno quando la terra si sbriciola.
- Pacciama l’aglio piantato in autunno e togli le erbe prima che fioriscano.
:::
:::
:::

## Consociazioni

Consociare vuol dire far crescere vicine piante che si giovano a vicenda. La :ref{id="consociazioni" style="full" case="lower"} riassume le coppie più citate negli orti di famiglia: in verde :swatch{color="leaf"} le favorevoli, in rosa :swatch{color="blush"} quelle da evitare, in crema :swatch{color="cream"} quelle senza effetti noti.

La cipolla tiene lontana la mosca della carota, e la carota quella della cipolla; il basilico accanto al pomodoro è un’abitudine antica. Patata e pomodoro invece temono la stessa peronospora, e l’aglio frena fagioli e cavoli. Sono consigli nati dall’esperienza più che da prove sperimentali, e conviene verificarli nel proprio orto, un’aiuola alla volta.

Le semine di tutto l’anno sono nella :ref{id="semine" style="full" case="lower"}, alle pagine seguenti: una riga per coltura e due caselle per ogni mese, una per quindicina.

:::paragraphs{style="colophon"}
Almanacco dell’orto 2027 · Piazzolla, Gilda Display e Commissioner (SIL OFL) · Testo e illustrazioni originali, CC BY 4.0.
:::
`; // the month's text, in Italian
const sowing = String.raw`Solanacee
Pomodoro: S feb2–mar2, T apr2–mag2
Peperone: S feb1–mar1, T mag1–mag2
Melanzana: S feb1–mar1, T mag1–mag2
Patata: T mar1–apr1
Cucurbitacee
Zucchina: S mar2–apr1, T apr2–mag1, C mag2–giu2
Cetriolo: S mar2–apr1, T mag1–mag2, C giu1–giu2
Zucca: C apr2–mag2
Melone: S mar2–apr1, T mag1–mag2
Anguria: S apr1, T mag1–mag2
Leguminose
Fagiolo: C apr2–lug1
Fagiolino: C apr2–lug2
Pisello: C feb1–mar2, C ott2–nov1
Fava: C feb1–mar1, C ott2–nov2
Crucifere
Cavolo cappuccio: S mar1–apr1, T apr2–mag2, S giu1–giu2, T lug1–lug2
Cavolfiore: S mag2–giu2, T lug1–lug2
Broccolo: S mag2–giu2, T lug1–ago1
Cavolo nero: S giu1–lug1, T lug2–ago2
Rucola: C mar1–mag1, C ago2–set2
Ravanello: C feb2–mag2, C ago2–ott1
Rapa: C ago1–set1
Liliacee
Aglio: T feb1–mar1, T ott2–dic1
Cipolla: S gen2–feb2, T mar2–apr2, C ago2–set1
Porro: S feb2–apr1, T mag2–lug1
Scalogno: T feb1–mar2
Ombrellifere
Carota: C feb2–lug1
Prezzemolo: C feb2–giu1
Sedano: S feb2–mar2, T mag1–giu1
Finocchio: C giu2–lug2
Composite
Lattuga: S gen2–feb2, T mar1–apr1, C apr2–ago2
Radicchio: C giu2–lug2
Indivia: S giu1–lug1, T lug2–ago2
Carciofo: T mar2–apr2
Chenopodiacee
Spinacio: C feb2–apr1, C ago2–ott1
Bietola: C mar2–giu2
Barbabietola: C mar2–giu1
Labiate
Basilico: S mar1–apr1, T mag1–giu1
Rosmarino: T mar2–apr2
Salvia: T mar2–apr2
`; // one line per crop, grouped by family
const companions = String.raw`	Pomodoro	Basilico	Carota	Cipolla	Aglio	Lattuga	Fagiolo	Zucchina	Cavolo	Patata
Pomodoro	=	+	+	+	+	+			−	−
Basilico	+	=								
Carota	+		=	+	+	+	+			
Cipolla	+		+	=		+	−			
Aglio	+		+		=	+	−		−	
Lattuga	+		+	+	+	=	+		+	
Fagiolo			+	−	−	+	=	+	+	+
Zucchina							+	=		−
Cavolo	−				−	+	+		=	+
Patata	−						+	−	+	=
`; // TSV: + good, − bad, blank neutral

// #region art: the opener's field, four moon phases and ten crops, in the page's colours
const n = (v) => +v.toFixed(2);
function mulberry32(seed) { // a seeded PRNG: the same seedlings in every capture
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const ART = { soil: '#4f3420', furrow: '#3e2818', pebble: '#8a6a4c', sun: '#f2d98f',
  carrot: '#df7a2e', garlic: '#f4ecdc', cabbage: '#7fa38c', potato: '#c9a066', dark: '#3f6b22' };
function field() { // sky, a low sun, soil in furrows and a row of seedlings, 210 × ART_H mm
  const rand = mulberry32(2027);
  const horizon = ART_H - 22;
  let out = `<rect width="210" height="${ART_H}" fill="${palette.sky}"/>`
    + `<circle cx="176" cy="30" r="12" fill="${ART.sun}"/>`
    + `<path d="M0 ${horizon} Q52 ${horizon - 2.5} 105 ${horizon} T210 ${horizon} `
    + `V${ART_H} H0Z" fill="${ART.soil}"/>`;
  for (let y = horizon + 5; y < ART_H; y += 5) { // furrows
    out += `<path d="M0 ${y} Q105 ${n(y - 1.6)} 210 ${y}" fill="none" stroke="${ART.furrow}" `
      + 'stroke-width="0.7"/>';
  }
  for (let i = 0; i < 36; i++) { // pebbles
    out += `<ellipse cx="${n(rand() * 210)}" cy="${n(horizon + 3 + rand() * 18)}" `
      + `rx="${n(0.5 + rand())}" ry="${n(0.3 + rand() * 0.5)}" fill="${ART.pebble}"/>`;
  }
  for (let x = 5; x < 207; x += 5.5 + rand() * 3) { // short under the proverb, taller to the right
    const h = 3 + Math.max(0, (x - 95) / 115) * 13 + rand() * 3, lean = (rand() - 0.5) * 3;
    const [tx, ty] = [n(x + lean), n(horizon - h)];
    const leaf = (dir, len, fill = palette.green) => `<ellipse cx="${n(tx + dir * len * 0.55)}" `
      + `cy="${n(ty - 0.6)}" rx="${n(len * 0.6)}" ry="${n(len * 0.24)}" `
      + `transform="rotate(${-dir * 24} ${tx} ${ty})" fill="${fill}"/>`;
    out += `<path d="M${n(x)} ${horizon + 0.5} Q${n(x + lean * 0.2)} ${n(horizon - h / 2)} `
      + `${tx} ${ty}" fill="none" stroke="${palette.green}" stroke-width="0.7" `
      + 'stroke-linecap="round"/>' + leaf(-1, 2 + h * 0.12) + leaf(1, 2 + h * 0.12);
    if (h > 10) out += leaf(1, 1.4 + h * 0.08, ART.dark); // a first true leaf on the tallest
  }
  return svg(210, ART_H, out);
}
// The moon cell is 4.2 mm wide inside its padding, so a viewBox unit is 0.42 mm: the disc drops
// 5.2 units (2.2 mm) to sit level with the day's figures in the next cell.
const MOON_DROP = 5.2;
function moon(phase) { // lit side in paper, the rest in ink: the northern hemisphere's view
  const [y, top, foot] = [5, 0.8, 9.2].map((v) => v + MOON_DROP);
  const disc = (fill, extra = '') => `<circle cx="5" cy="${y}" r="4.2" fill="${fill}"${extra}/>`;
  const half = (sweep) => `<path d="M5 ${top}A4.2 4.2 0 0 ${sweep} 5 ${foot}Z" `
    + `fill="${palette.paper}"/>`;
  const lit = { 'luna-nuova': '', 'primo-quarto': half(1), 'luna-piena': disc(palette.paper),
    'ultimo-quarto': half(0) }[phase];
  return svg(10, 10 + MOON_DROP, disc(palette.ink) + lit
    + disc('none', ` stroke="${palette.ink}" stroke-width="0.6"`));
}
const P = palette;
const VEG = { // 20 × 20 drawings, in the order of the matrix's rows
  pomodoro: `<circle cx="10" cy="11.5" r="7" fill="${P.red}"/><path d="M10 4.4l1.2 2.4 `
    + '2.6-.8-1.6 2 2 1.6-2.6.2-.2 2.4-1.4-2-1.4 2-.2-2.4-2.6-.2 2-1.6-1.6-2 2.6.8Z" '
    + `fill="${P.green}"/>`,
  basilico: `<path d="M10 19V6" stroke="${P.green}" stroke-width="1"/><ellipse cx="6.5" cy="12" `
    + `rx="4.2" ry="2.4" transform="rotate(-30 6.5 12)" fill="${P.green}"/><ellipse cx="13.5" `
    + `cy="10" rx="4.2" ry="2.4" transform="rotate(30 13.5 10)" fill="${P.green}"/>`
    + `<ellipse cx="10" cy="4.5" rx="2" ry="3.4" fill="${ART.dark}"/>`,
  carota: `<path d="M6 6.5h8L10.6 19a.6.6 0 0 1-1.2 0Z" fill="${ART.carrot}"/><path d="M10 `
    + `6.5 7 1.5M10 6.5V1M10 6.5l3-5" stroke="${P.green}" stroke-width="1.1" `
    + 'stroke-linecap="round"/>',
  cipolla: '<path d="M10 3c1 3.5 6.5 5.5 6.5 10.2C16.5 17 13.4 18.6 10 18.6S3.5 17 3.5 13.2C3.5 '
    + `8.5 9 6.5 10 3Z" fill="${P.ochre}"/><path d="M10 5.5c-2 3-3 6-2.4 12.6M10 5.5c2 3 3 6 `
    + `2.4 12.6" fill="none" stroke="${P.brown}" stroke-width="0.5"/>`,
  aglio: '<path d="M10 3.5c.8 3 6.2 5 6.2 9.5 0 3.8-3 5.5-6.2 5.5S3.8 16.8 3.8 13c0-4.5 5.4-6.5 '
    + `6.2-9.5Z" fill="${ART.garlic}" stroke="${P.brown}" stroke-width="0.5"/><path d="M10 `
    + '7v11.4M7 9.4c-1 3-1 6 0 8.6M13 9.4c1 3 1 6 0 8.6" fill="none" '
    + `stroke="${P.rule}" stroke-width="0.5"/>`,
  lattuga: `<circle cx="10" cy="11" r="7.4" fill="${P.green}"/><circle cx="7.2" cy="9.5" `
    + `r="3.6" fill="${P.leaf}"/><circle cx="12.8" cy="9.5" r="3.6" fill="${P.leaf}"/>`
    + `<circle cx="10" cy="12.6" r="3.8" fill="${P.leaf}"/><circle cx="10" cy="11" r="1.6" `
    + `fill="${P.green}"/>`,
  fagiolo: '<path d="M3 5c4 1 6 4 8 8s4 5 6.5 5.5c-2 1.5-6 .8-8.8-2.6C6 12.8 4.4 9 3 5Z" '
    + `fill="${P.green}"/>` + [[7.4, 10], [10.4, 13.6], [13.6, 16.2]].map(([x, y]) =>
    `<circle cx="${x}" cy="${y}" r="1.2" fill="${P.leaf}"/>`).join(''),
  zucchina: '<rect x="2" y="8" width="16.5" height="5.4" rx="2.7" transform="rotate(-28 10 10.7)" '
    + `fill="${ART.dark}"/><path d="M4.4 14.6 15.6 8.6" stroke="${P.leaf}" stroke-width="0.6"/>`
    + `<path d="M17.4 5.2l1.8-1.2" stroke="${P.brown}" stroke-width="1.4" stroke-linecap="round"/>`,
  cavolo: `<circle cx="10" cy="11" r="7.6" fill="${ART.cabbage}"/><path d="M10 18.4V5.2M10 9 `
    + '6 6.4M10 12 5 9.6M10 9l4-2.6M10 12l5-2.4M10 15l-4.4-2M10 15l4.4-2" fill="none" '
    + `stroke="${P.leaf}" stroke-width="0.7"/>`,
  patata: `<path d="M4 9c1-4 7-5 11-3s3.6 8.4-.4 10.6S2.8 14 4 9Z" fill="${ART.potato}"/>`
    + [[8, 9], [12.6, 11.4], [9.2, 14]].map(([x, y]) =>
      `<circle cx="${x}" cy="${y}" r=".6" fill="${P.brown}"/>`).join(''),
};
// Each drawing is an SVG resource, registered for the canvas under its fileId.
const drawings = [['campo', field(), [210, ART_H], 'Piantine appena nate in file sulla terra'],
  ...PHASES.map((p) => [p, moon(p), [10, 10 + MOON_DROP], p.replace('-', ' ')]),
  ...Object.entries(VEG).map(([name, body], i) => [`veg-${i + 1}`, svg(20, 20, body), [20, 20],
    name])];
const pictures = drawings.map(([id, , [w, h], altText]) => ({ id, typeId: 'figure', kind: 'svg',
  altText, createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10,
    height: h * 10 } })); // the size sets the aspect ratio: the cell or the design sets the width
for (const [id, markup] of drawings) await loadSvg(`${id}.svg`, markup);
// #endregion

// #region resources: the three tables, keyed by colour swatches in captions and notes
const resourceTypes = [ // 1.4.1 has English and Spanish ones (gotcha: resource-types-locale)
  { id: 'table', name: 'Tabella', shortLabel: 'Tab.', captionPrefix: 'Tabella',
    captionStyle: { position: 'above' } },
  { id: 'calendar', name: 'Calendario', shortLabel: 'Cal.', captionPrefix: '' }, // no label
].map((t) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...t }));
const table = (id, typeId, caption, model, styleId, extra) => ({ id, typeId, kind: 'table',
  caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...extra });
const foot = { position: 'bottom', span: 'page' }; // across both columns, at the page's foot
const calendar = calendarTable(); // its key names the quarters the grid draws
const resources = [...pictures, // never cited: the opener and the cells draw them by id
  table('calendario', 'calendar', ':swatch{color="cream"} domeniche · :swatch{color="blush"} '
    + `Pasqua e Pasquetta · ${calendar.moons}, sul mese sinodico medio: un giorno prima o dopo `
    + 'è possibile. Per tradizione in crescente si semina ciò che fruttifica sopra terra, in '
    + 'calante le radici.', calendar.model, 'calendario', { placement: foot }),
  table('consociazioni', 'table', 'Consociazioni tra dieci ortaggi', companionTable(companions),
    'matrice', { placement: foot, note: ':swatch{color="leaf"} + favorevole · '
      + ':swatch{color="blush"} − da evitare · :swatch{color="cream"} nessun effetto noto. '
      + 'Indicazioni della tradizione orticola.' }),
  // Each part of a split table repeats its caption, so the key goes there; the note ends the last.
  table('semine', 'table', 'Semine al Nord e al Centro, in pianura e collina: '
    + ':swatch{color="ochre"} in semenzaio protetto · :swatch{color="green"} in piena terra · '
    + ':swatch{color="brown"} trapianto o messa a dimora', sowingChart(sowing), 'semine', {
    placement: chartPlacement, note: 'Al Sud e lungo le coste le date si anticipano di '
      + 'due-quattro settimane; in montagna si ritardano.' }),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first)
  Piazzolla: ['400', '400i', '600'], // text, notes and proverb; 600 for caption labels
  'Gilda Display': ['400'], Commissioner: ['600'] }; // display and days; labels and table heads

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, sowing, companions].join('\n');
await loadFonts(FONTS, allText);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: 'Almanacco dell’orto 2027 · Marzo' });

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

## Variations

### Turn the chart clockwise

The head then faces the right edge of the page, and the reader turns the book the other way.

```diff
-const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
+const chartPlacement = { rotate: 'cw' }; // a float, never 'here' (gotcha: here-table-no-split)
```

## Pitfalls

- **A 'here' table never splits.** Only floated tables split across columns and pages; a table placed 'here' moves whole. Let a long table float, or keep inline tables short.
- **Merged cells need hiddenBy placeholders: use mergeCells.** Cells are laid out by their position in the row array, so a merged cell needs placeholder cells marked hiddenBy where it spreads; leaving them out, as HTML does, shifts every later column. Build merges with mergeCells.
- **Localise Figure/Table with defaultResourceTypes(locale).** The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes.
- **An opener's images never count towards the height it reserves.** In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **A design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **Design text overflow defaults to 'ellipsis-end'.** A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines.
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.

- A picture in a table cell is always drawn above the cell's text and aligned like it. To set an icon beside a number, give the icon a column of its own, as the calendar does with the moon.
- The note under a split table appears on its last part only, so a key that every part needs belongs in the caption.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The Italian text of the March pages, with the table captions, the notes and the colophon, written for this recipe: Ignacio Ferro, CC-BY-4.0
- Text: The proverb “Marzo asciutto, aprile bagnato, beato il villan che ha seminato”, a traditional Italian saying: Proverbio contadino, public domain
- Type: Piazzolla (OFL-1.1), Gilda Display (OFL-1.1), Commissioner (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 067 · Atlas sheet: numbered maps and a rotated overview](https://postext.dev/en/cookbook/atlas-map-sheet.md): Maps with a numbering of their own and captions on a blue bar, a key of swatches in the maps' colours, and a sea chart turned sideways on page 3. · Level 3 (Advanced) · Manuals, guides & reference
- [Nº 037 · Annual report with flush columns](https://postext.dev/en/cookbook/annual-report-flush-columns.md): An energy co-op's annual report in two justified columns that end on the same grid line, with a key-figures box across the page and a closing page cut level. · Level 3 (Advanced) · Reports
- [Nº 052 · Bistro menu: prices aligned without tab stops](https://postext.dev/en/cookbook/bistro-menu.md): A bistro menu printed on both sides, with the prices set in borderless tables and a brass rule anchored to either side of each course head. · Level 2 (Intermediate) · Single sheets & ephemera
