Vai al contenuto principale
Ricetta numero 35

Ricettario · Capitolo 8 · Tabelle

Almanacco dell’orto: calendario e tabella orizzontale

Una tabella delle semine di 38 colture girata in orizzontale su due pagine, un calendario calcolato dalle date e una matrice delle consociazioni a colori.

p. 27 · 1 di 4

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 210 × 280 mm
  • 2 colonne, Intercolonna di 7 mm
  • Piazzolla 9,8/14
  • Commissioner
  • Gilda Display
  • 4 pagine
  • Livello
  • Postext 1.31.0
  • Impaginato in 85 ms
  • 251 righe di codice

In breve

Le pagine di marzo di un almanacco per l’orto, con un calendario del mese e tabelle colorate. Mostra come costruire tabelle dai dati e girare di lato una tabella troppo larga per la pagina.

Che cosa comporrai

Le pagine di marzo di un almanacco dell’orto scritto in italiano. La pagina 27 si apre su un acquerello di piantine nella terra scura sotto un cielo pallido, con il nome del mese a 84 pt e un proverbio contadino. Sotto due colonne di testo viene il calendario del mese, che il pen calcola dalle date: domeniche in rosso su una colonna crema, domenica e lunedì di Pasqua sul rosa, e i quattro quarti di luna disegnati accanto ai loro giorni. La pagina 28 elenca i lavori del mese in un riquadro a due colonne e colora di verde, rosa o crema una matrice dieci per dieci delle consociazioni, con il disegno di ogni coltura sulla diagonale. Le date di semina dell’anno, 38 colture per quindicina, sono più larghe della pagina, quindi la tabella gira di un quarto sulle pagine 29 e 30 e ripete l’intestazione sulla seconda.

Questa ricetta risponde a

  • Come compongo una tabella larga in orizzontale su una pagina tutta sua?
  • Come creo una tabella con righe di intestazione, celle unite, larghezze delle colonne e allineamento per cella?
  • Come do stili diversi a più tabelle (riempimenti, celle a righe alterne, cornici arrotondate) nello stesso documento?
  • Come metto immagini o icone dentro le celle di una tabella?
  • Come aggiungo campioni di colore come legenda nel testo, nelle didascalie o nelle note di tabella?

La risposta breve

script.js · righe 31–67nel codice completo
// 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: 'here' ignores the turn
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
}

Ingredienti

Tipo
Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1)
Risorse
  • campo-1680.jpg
  • The opener’s field of seedlings in March, a watercolour (Generated With Diffusion Models, originale)
  • The moon’s quarters and the ten crops, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)

Preparazione

#1 · Gira la tabella e lasciala proseguire

Il codice è nella risposta breve qui sopra. rotate: 'ccw' fa della tabella un elemento flottante a tutta pagina su pagine proprie (Posizionamento), con l’intestazione verso il bordo sinistro della pagina, e si appoggia al dorso sia sul recto sia sul verso. Si impagina lungo l’altezza dell’area del testo, 227 dei suoi 237 mm: le 47 righe intere della griglia di 14 pt che ci stanno, meno una riga per lo spazio dell’elemento flottante. Le sue righe si impilano lungo i 176 mm di larghezza, e quando li superano la tabella viene tagliata tra due righe e prosegue nella pagina seguente sotto le sue due righe di intestazione e la didascalia, che finisce con (segue) (Tabelle più alte della pagina). Solo un elemento flottante può girare: con position: 'here' la rotazione viene ignorata, e la tabella verrebbe tagliata in verticale, tra le righe, dove finisce la sua colonna. L’intestazione è unita in tre modi: Coltura su entrambe le righe di intestazione, ogni stagione sopra i suoi mesi, e ogni mese sopra le sue due quindicine.

#2 · Un calendario calcolato dalle date

script.js · righe 71–110nel codice completo
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(', ') };
}

I numeri di giorno di Date.UTC danno a ogni data il suo giorno della settimana, e la griglia comincia lunedì 1 marzo e arriva a cinque settimane. La Pasqua viene dal computo e i quarti di luna dal mese sinodico medio. Calcolato così, un quarto può cadere un giorno prima o dopo, come avverte la didascalia. Il ciclo che disegna ogni luna ne scrive anche la data nella didascalia. L’immagine di una cella sta sempre sopra il suo testo, ed è per questo che ogni giorno occupa due colonne, quella della luna e quella della data: una luna nella cella della data spingerebbe la data più in basso solo in quattro giorni. Domeniche e feste sono rosse grazie a uno stile di chip che cambia solo il colore, e le note sono chip a metà corpo. Un giorno senza nota riceve come seconda riga un word joiner (U+2060), che mantiene le cinque settimane alla stessa altezza.

#3 · Colora la matrice da simboli incollati

script.js · righe 114–130nel codice completo
const FILLS = { '+': 'leaf', '−': 'blush', '': 'cream' }; // good, bad, no known effect
function companionTable(tsv) {
  let m = { ...parseTSV(tsv, { headerRows: 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;
}

Le coppie sono scritte in TSV, un simbolo per cella. parseTSV(tsv, { headerRows: 1 }) fa della riga dei nomi delle colture l’intestazione. I pesi di columnWidths sono millimetri della giustezza di 176 mm: 26 per i nomi, 15 per ogni coltura. Ogni simbolo nomina una voce della palette, che setCellBackground applica come fondo della cella; il + e il − vengono anche stampati, in un chip a 1,4 em, così la matrice si legge ancora in una copia in scala di grigi. La diagonale accoppia una coltura con sé stessa, e lì setCellImage mette il disegno della coltura, al 62 % della larghezza interna della cella.

#4 · Una legenda dei colori con i campioni

script.js · righe 396–420nel codice completo
const resourceTypes = [ // the almanac's own two: tables numbered through, a bare calendar
  { 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 quadrato di tre quarti del corpo, riempito con una voce della palette e contornato nel colore del testo; queste pagine lo usano nel testo corrente, in due didascalie e in una nota. La legenda della tabella delle semine va nella didascalia, perché ogni parte di una tabella spezzata ripete la didascalia e la nota si compone solo sotto l’ultima parte. Il calendario è un tipo di risorsa a sé con un prefisso di didascalia vuoto, e la sua didascalia si apre sulla legenda invece che su un’etichetta Tabella. position: 'bottom' con span: 'page' compone il calendario e la matrice su entrambe le colonne al piede delle loro pagine. La pagina 28 è l’ultima pagina di testo. Sulla pagina di chiusura di un capitolo un elemento flottante a tutta pagina sale fino a una riga sotto il testo, ed è per questo che la matrice finisce 10 mm sopra il margine inferiore; layout.hugClosingFloats: false la lascerebbe al piede.

#5 · Uno stile della casa e una variante per tabella

script.js · righe 134–145nel codice completo
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),
  // An Italian document's split table says '(segue)' and 'Continua'; this spells the second out.
  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 fissa lo stile della casa: un’intestazione color inchiostro con etichette color carta in Commissioner, filetti sottili tra le righe e la riga che chiude ogni parte di una tabella spezzata. Un documento italiano chiude una parte con Continua e aggiunge (segue) alla didascalia della parte seguente; l’almanacco scrive la prima per esteso, Continua alla pagina seguente. Ogni stile con nome dichiara solo ciò che la sua tabella cambia. Il calendario compone i giorni in Gilda Display a 15 pt; la matrice toglie il fondo dell’intestazione; la matrice e la tabella delle semine disegnano filetti di griglia nel colore della carta, spessi 2 pt e 1 pt, che tagliano i loro fondi in tessere.

#6 · Il mese si apre su un dipinto

script.js · righe 149–169nel codice completo
const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: painting, 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 }); // a long line takes a second one, in the heads too
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// minHeight: a floor under what the painting reserves, the first grid line AIR or more below 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 of the size: set solid
  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)),
] } };

Il titolo di primo livello si disegna da uno slot di elementi. L’acquerello è un elemento immagine in testa alla pagina, e l’occhiello, il proverbio e la sua fonte vengono dagli attributi del titolo. L’apertura riserva lo spazio fino al piede del dipinto, e minHeight è un minimo fissato un po’ più in basso: 92 mm di dipinto più 5 mm d’aria, meno il margine superiore di 22 mm, fa 75 mm, che si arrotonda a 16 righe di 14 pt (79 mm). La prima riga di testo sta a 101 mm dalla sommità della pagina, 9 mm sotto la terra. Il titolo si sposta di 1,5 mm a sinistra, lo spazio laterale della M di Gilda Display a 84 pt, e la grazia della M si allinea all’occhiello; il proverbio torna indietro della stessa misura.

La ricetta completa

Sandbox
// ═══ Postext Cookbook · Nº 035 · Garden almanac: calendar grid and landscape chart ═════
// https://postext.dev/en/cookbook/garden-almanac
// Code: MIT · Text: original, Italian (CC BY 4.0) · Field: diffusion models · Icons: CC BY 4.0
// Fonts: Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, 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
// col() links a colour to its palette entry; the hex is the value the entry holds.
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: 'here' ignores the turn
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, { headerRows: 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),
  // An Italian document's split table says '(segue)' and 'Continua'; this spells the second out.
  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 painting, with its proverb from the heading's attributes
const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: painting, 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 }); // a long line takes a second one, in the heads too
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// minHeight: a floor under what the painting reserves, the first grid line AIR or more below 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 of the size: set solid
  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 = () => ({
  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: [
    // parity 'odd': the month opens on a recto, after a blank verso only when one is needed.
    { 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`---
Esempio Markdown · 38 righe · content.en.mdtitle: "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. :::space{lines=1.5} :::paragraphs{style="colophon"} Almanacco dell’orto 2027 · Piazzolla, Gilda Display e Commissioner (SIL OFL) · Testo e icone originali, CC BY 4.0 · Acquerello generato. :::
`; // the month's text, in Italian const sowing = String.raw`Solanacee
Esempio Markdown · 46 righe · content.semine.en.mdPomodoro: 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
Esempio Markdown · 10 righe · content.consociazioni.en.mdPomodoro = + + + + + − − Basilico + = Carota + = + + + + Cipolla + + = + − Aglio + + = + − − Lattuga + + + + = + + Fagiolo + − − + = + + + Zucchina + = − Cavolo − − + + = + Patata − + − + =
`; // TSV: + good, − bad, blank neutral // #region art: four moon phases and ten crops, drawn in the page's colours 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 = { carrot: '#df7a2e', garlic: '#f4ecdc', cabbage: '#7fa38c', potato: '#c9a066', dark: '#3f6b22' }; // 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 = [...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); // The opener's field is a watercolour, a JPEG in assets/ cut to 210 × ART_H mm, at its pixels. pictures.push({ id: 'campo', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText: 'Piantine appena nate in file sulla terra', bitmap: { fileId: 'campo-1680.jpg', format: 'jpeg', width: 1680, height: 736 } }); await loadImage('campo-1680.jpg', asset('campo-1680.jpg')); // #endregion // #region resources: the three tables, keyed by colour swatches in captions and notes const resourceTypes = [ // the almanac's own two: tables numbered through, a bare calendar { 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 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 prepareFonts(allText, config(), kitFonts(FONTS)); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), { ...kitFonts(FONTS), text: allText }); showPages(doc, { title: 'Almanacco dell’orto 2027 · Marzo' });
Kit · core, fonts, viewer, images: uguale in ogni ricetta · 270 righe// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Gira la tabella in senso orario

L’intestazione guarda allora il bordo destro della pagina, e il lettore gira il libro dall’altra parte.

-const chartPlacement = { rotate: 'ccw' }; // a float: 'here' ignores the turn
+const chartPlacement = { rotate: 'cw' }; // a float: 'here' ignores the turn

Errori frequenti

Errore frequente

Le celle unite richiedono segnaposto hiddenBy: usa mergeCells

Le celle si dispongono secondo la loro posizione nell’array della riga, quindi una cella unita richiede celle segnaposto marcate hiddenBy dove si estende; ometterle, come fa l’HTML, sposta tutte le colonne successive. Costruisci le unioni con mergeCells. Da postext 1.5 una tabella con una griglia irregolare viene segnalata in doc.contentWarnings come raggedTableGrid, e parseTSV(text, { headerRows }) marca le righe di testa. Tabelle dai dati →

  • Un’immagine in una cella di tabella si disegna sempre sopra il testo della cella e si allinea come lui. Per mettere un’icona accanto a un numero, dai all’icona una colonna propria, come fa il calendario con la luna.
  • La nota sotto una tabella spezzata compare solo sull’ultima parte, quindi una legenda che serve a ogni parte va nella didascalia.

Crediti

Testo
  • 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
  • The proverb “Marzo asciutto, aprile bagnato, beato il villan che ha seminato”, a traditional Italian saying · Proverbio contadino · pubblico dominio
Immagini
  • The opener’s field of seedlings in March, a watercolour · Generated With Diffusion Models · originale
  • The moon’s quarters and the ten crops, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
Font
Piazzolla (SIL OFL 1.1) · Gilda Display (SIL OFL 1.1) · Commissioner (SIL OFL 1.1)
Sandbox