# Almanaque del huerto: calendario y gráfico apaisado

> Un gráfico de siembras apaisado en dos páginas, un calendario calculado con fechas y una matriz de cultivos asociados, coloreados con la paleta.

- Versión HTML: https://postext.dev/es/cookbook/garden-almanac
- Receta N.º 035 · Tablas · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Manuales, guías y obras de consulta
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [27](https://postext.dev/cookbook/garden-almanac/es/p01.webp?v=91c9926f), [28](https://postext.dev/cookbook/garden-almanac/es/p02.webp?v=91c9926f), [29](https://postext.dev/cookbook/garden-almanac/es/p03.webp?v=91c9926f), [30](https://postext.dev/cookbook/garden-almanac/es/p04.webp?v=91c9926f)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/garden-almanac.md)

## Lo que vas a componer

Las páginas de marzo de un almanaque de huerto en italiano. La página 27 se abre con un dibujo de plantones en tierra oscura bajo un cielo pálido, «Marzo» a 84 pt y un refrán campesino. Bajo dos columnas de texto va el calendario del mes, que el pen calcula con las fechas: los domingos en rojo sobre una columna crema, el domingo y el lunes de Pascua sobre rosa y las fases de la luna junto a sus días. La página 28 reúne las tareas del mes en un recuadro de dos columnas y colorea de verde, rosa o crema una matriz de diez por diez de cultivos asociados, con el dibujo de cada cultivo en la diagonal. El gráfico de siembras, 38 cultivos por quincenas, no cabe a lo ancho y va girado en las páginas 29 y 30, con la cabecera repetida en la segunda.

**Esta receta responde a:**

- ¿Cómo compongo una tabla ancha en apaisado, en páginas propias y con la cabecera repetida al continuar?
- ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?
- ¿Cómo doy estilos distintos a varias tablas (rellenos, filas alternas, marcos redondeados) en un mismo documento?
- ¿Cómo pongo imágenes o iconos dentro de las celdas de una tabla?
- ¿Cómo añado muestras de color como leyenda en el texto, los pies o las notas de tabla?

## La respuesta corta

```js
// script.js, líneas 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
}
```

## Ingredientes

**Enseña**

- [Tablas y figuras apaisadas](https://postext.dev/es/docs/document-format.md#colocación): Una tabla o figura ancha girada un cuarto de vuelta en una página propia, junto al lomo; si la tabla no cabe, sigue en la página siguiente, cortada entre filas.
- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Tablas como recursos con filas de cabecera, celdas combinadas, proporciones de columna, alineación por celda y listas dentro de las celdas; las tablas con barras no se interpretan.
- [Rellenos de celda](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Un fondo por celda enlazado a la paleta, para mapas de calor, matrices de compatibilidad y filas alternas hechas a mano.

**También usa**

- [Tablas que pasan de página](https://postext.dev/es/docs/configuration.md#tablas-más-altas-que-la-página)
- [Estilos de tabla con nombre](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)
- [Estilo de tablas](https://postext.dev/es/docs/configuration.md#estilo-de-tablas)
- [Imágenes en las celdas](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Muestras de color](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip)
- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Líneas de fuente y crédito](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-soportados)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Recuadros anidados](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Preliminares en romanos](https://postext.dev/es/docs/document-format.md#numbering)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`chipStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`tableStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`mergeCells`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`parseTSV`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`setAlignment`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`setCellBackground`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), `setCellContent`, [`setCellImage`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)

**Tipografías**

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

## Elaboración

### 1 · Gira el gráfico y deja que continúe

El código es [la respuesta corta](#la-respuesta-corta) de arriba. Con `rotate: 'ccw'`, la tabla pasa a ser un flotante a lo ancho de la página, en páginas propias ([Colocación](/es/docs/document-format#colocación)), con la cabecera hacia el borde izquierdo, y se pega al lomo tanto en la página impar como en la par. Girada, la tabla se compone a lo alto de la caja de texto y ocupa 227 de sus 237 mm: las 47 líneas enteras de 14 pt que caben en la rejilla, menos una para la separación del flotante. Sus filas se apilan a lo ancho de los 176 mm de la caja; cuando ya no caben, la tabla se corta entre dos filas y continúa en la página siguiente, bajo sus dos filas de cabecera y su pie, que termina en `(segue)` ([Tablas más altas que la página](/es/docs/configuration#tablas-más-altas-que-la-página)). Solo un flotante puede girar o partirse: con `position: 'here'` el giro se ignora y la tabla nunca se parte. La cabecera lleva tres clases de celdas combinadas: `Coltura` ocupa sus dos filas, cada estación abarca sus meses y cada mes, sus dos quincenas.

### 2 · Un calendario calculado con fechas

```js
// script.js, líneas 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(', ') };
}
```

Con `Date.UTC`, cada fecha se convierte en un número de día, y de ese número sale el día de la semana; la cuadrícula empieza el lunes 1 de marzo y ocupa cinco semanas. La Pascua sale del cómputo eclesiástico, y las fases de la luna, del mes sinódico medio. Con ese cálculo, una fase puede caer un día antes o después, como advierte el pie. El mismo bucle que dibuja cada luna escribe su fecha en el pie. La imagen de una celda siempre va encima de su texto; por eso cada día ocupa dos columnas, la de la luna y la de la fecha: una luna en la celda de la fecha bajaría la cifra solo en cuatro días. Los domingos y las fiestas salen en rojo con un estilo de chip que solo cambia el color, y las notas son chips a la mitad del cuerpo. Un día sin nota lleva como segunda línea un unidor de palabras (U+2060), y así las cinco semanas tienen la misma altura.

### 3 · Colorea la matriz a partir de símbolos pegados

```js
// script.js, líneas 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;
}
```

Las parejas se escriben como TSV, un símbolo por celda. En postext 1.4.1, `parseTSV` crea celdas normales y deja `headerRowCount` sin fijar, así que se añade a mano `headerRowCount: 1` para que la fila con los nombres de los cultivos sea la cabecera. Los pesos de `columnWidths` reparten en milímetros los 176 mm de la medida: 26 para los nombres y 15 para cada cultivo. Cada símbolo corresponde a una entrada de la paleta, que `setCellBackground` pone de fondo en la celda; el `+` y el `−` se imprimen también, en un chip a 1,4 em, para que la matriz se lea en una copia en escala de grises. La diagonal empareja cada cultivo consigo mismo, y ahí `setCellImage` pone su dibujo, al 62 % del ancho interior de la celda.

### 4 · Pon la leyenda con muestras de color

```js
// script.js, líneas 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"}` compone un cuadrado de tres cuartos del cuerpo, relleno con una entrada de la paleta y perfilado en el color del texto; estas páginas lo usan en el texto corrido, en dos pies y en una nota. La leyenda del gráfico va en su pie, porque cada parte de una tabla partida repite el pie, mientras que la nota solo sale bajo la última. El calendario es un tipo de recurso propio con el prefijo del pie vacío, y su pie empieza por la leyenda y no por una etiqueta `Tabella`. `position: 'bottom'` con `span: 'page'` coloca el calendario y la matriz a lo ancho de las dos columnas, al pie de su página. La página 28 es la última con texto. En una página así, 1.4.1 sube el flotante a lo ancho de la página hasta una línea por debajo del texto; por eso la matriz termina 10 mm por encima del margen inferior.

### 5 · Un estilo de la casa y una variante por tabla

```js
// script.js, líneas 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` fija el estilo de la casa: cabecera con fondo de tinta y rótulos en Commissioner del color del papel, filetes finos entre filas y los textos de continuación en italiano, que 1.4.1 solo trae en inglés y en español. Cada estilo con nombre indica solo lo que cambia su tabla. El calendario compone los días en Gilda Display a 15 pt; la matriz quita el relleno de la cabecera, y la matriz y el gráfico trazan una rejilla de filetes del color del papel, de 2 pt y 1 pt, que corta sus rellenos en casillas.

### 6 · El mes se abre con un dibujo

```js
// script.js, líneas 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)),
] } };
```

El título de primer nivel se dibuja con una ranura de elementos. El dibujo es un elemento de imagen en la cabeza de la página, y el antetítulo, el refrán y su fuente salen de los atributos del título. En 1.4.1 un elemento de imagen no reserva altura, y `minHeight` lo compensa: 92 mm de dibujo más 5 mm de aire, menos los 22 mm del margen superior, dan 75 mm, que se redondean hacia arriba a 16 líneas de 14 pt (79 mm). La primera línea de texto queda a 101 mm del borde superior de la página, 9 mm por debajo de la tierra. El título se desplaza 1,5 mm a la izquierda, lo que mide el blanco lateral de la M de Gilda Display a 84 pt, y así la gracia de la M se alinea con el antetítulo; el refrán se desplaza lo mismo hacia la derecha.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 = 'es'; // @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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Gira el gráfico en el sentido de las agujas del reloj

La cabecera mira entonces al borde derecho de la página, y el lector gira el libro hacia el otro lado.

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

## Errores frecuentes

- **Una tabla 'here' nunca se parte.** Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea.
- **Las celdas combinadas necesitan hiddenBy: usa mergeCells.** Las celdas se colocan según su posición en la fila, así que una celda combinada necesita celdas de relleno marcadas con hiddenBy donde se extiende; omitirlas, como en HTML, desplaza todas las columnas siguientes. Combina celdas con mergeCells.
- **Traduce Figura y Tabla con defaultResourceTypes(locale).** El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

- La imagen de una celda de tabla se dibuja siempre encima del texto de la celda y con su misma alineación. Para poner un icono junto a una cifra, dale al icono una columna propia, como hace el calendario con la luna.
- La nota de una tabla partida solo aparece bajo la última parte; una leyenda que necesiten todas las partes va en el pie.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: El texto italiano de las páginas de marzo, con los pies y las notas de las tablas y el colofón, escrito para esta receta: Ignacio Ferro, CC-BY-4.0
- Texto: El refrán «Marzo asciutto, aprile bagnato, beato il villan che ha seminato», un dicho tradicional italiano: Proverbio contadino, dominio público
- Tipografías: Piazzolla (OFL-1.1), Gilda Display (OFL-1.1), Commissioner (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 067 · Hoja de atlas: mapas numerados y una carta girada](https://postext.dev/es/cookbook/atlas-map-sheet.md): Mapas con numeración propia y el pie sobre una barra azul, una leyenda de muestras en los colores de los mapas y una carta náutica girada en la página 3. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 037 · Memoria anual con columnas a ras](https://postext.dev/es/cookbook/annual-report-flush-columns.md): La memoria anual de una cooperativa energética a dos columnas que acaban en la misma línea, con un recuadro de cifras a lo ancho y la última página igualada. · Nivel 3 (Avanzado) · Informes y memorias
- [N.º 052 · Carta de bistró: precios alineados sin tabuladores](https://postext.dev/es/cookbook/bistro-menu.md): Una carta de bistró impresa por las dos caras, con los precios en tablas sin filetes y un filete de latón anclado a cada lado de los títulos de apartado. · Nivel 2 (Intermedio) · Hojas sueltas y efímeros
