What you'll build
The March pages of a gardener's almanac written in Italian. Page 27 opens on a drawing of seedlings in dark soil under a pale sky, with the month's name at 84 pt and a country proverb. Under two columns of text comes the month's calendar, which the pen computes from dates: Sundays in red on a cream column, Easter Sunday and Monday on pink, and the moon's four quarters drawn beside their days. Page 28 lists the month's work in a two-column box and colours a ten-by-ten companion-planting matrix green, pink or cream, with a drawing of each crop on the diagonal. The year's sowing dates, 38 crops by fortnight, are wider than the page, so the chart turns a quarter onto pages 29 and 30 and repeats its head on the second.
This recipe answers
- How do I set a wide table in landscape on its own page?
- How do I make a table with header rows, merged cells, column widths and per-cell alignment?
- How do I style several tables differently (fills, zebra cells, rounded frames) in one document?
- How do I put pictures or icons inside table cells?
- How do I add colour-key swatches to text, captions or table notes?
The short answer
// 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
}
A crops × fortnights chart, turned to landscape on pages of its own
Ingredients
- Features
- Landscape tables and figuresTables from dataCell fillsTables across pagesNamed table stylesTable stylePictures in table cellsColour swatchesInline chipsColumns inside a boxDesigned openersFull-width chapter bandHeading attributesSemantic colour paletteMirrored marginsSource and credit linesFigure and Table in your languageHyphenation and document languageFigures and tables as resourcesPages on a canvas
- Also uses
- Callout boxesCitations that place figuresNested boxesPaper colourHeads by page roleParagraph stylesCustom resource typesRoman front matter
- Type
- Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Turn the chart and let it run on
The code is the short answer above. rotate: 'ccw' makes the table a page-span float on pages of its own (Placement), with its head towards the left edge of the page, and it sits against the spine on both the recto and the verso. It is laid out along the height of the text area, 227 of its 237 mm: the 47 whole 14 pt grid lines that fit, less one line for the float gap. Its rows stack across the 176 mm width, and when they outrun it the table is cut between two rows and goes on over the page under its two head rows and its caption, which ends in (segue) (Tables taller than the page). Only a float can turn or split: at position: 'here' the rotation is ignored and the table never splits. The head is merged three ways: Coltura down both head rows, each season over its months, and each month over its two fortnights.
#2 · A calendar computed from dates
const [YEAR, MONTH, DAY] = [2027, 3, 24 * 60 * 60 * 1000]; // DAY in ms
const epochDay = (m, d) => Date.UTC(YEAR, m - 1, d) / DAY; // 1 January 1970 was a Thursday
const weekday = (d) => (epochDay(MONTH, d) + 3) % 7; // 0 is Monday: Italian weeks start there
function easter(y) { // the Gregorian computus (Meeus): [month, day]
const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4);
const g = Math.floor((8 * b + 13) / 25), h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * (b % 4) + 2 * i - h - k) % 7;
const n = h + l - 7 * Math.floor((a + 11 * h + 22 * l) / 451) + 114;
return [Math.floor(n / 31), (n % 31) + 1];
}
// The moon's age: mean synodic months since the new moon of 6 January 2000 at 18.14 UT.
const [SYNODIC, NEW_MOON] = [29.530588853, Date.UTC(2000, 0, 6, 18, 14) / DAY];
const quarter = (day) => Math.floor((((day - NEW_MOON) % SYNODIC) / SYNODIC) * 4); // 0–3
const PHASES = ['luna-nuova', 'primo-quarto', 'luna-piena', 'ultimo-quarto'];
const JOINER = '\u2060'; // a word joiner: the second line of a day without a note
const DAYS = ['LUNEDÌ', 'MARTEDÌ', 'MERCOLEDÌ', 'GIOVEDÌ', 'VENERDÌ', 'SABATO', 'DOMENICA'];
function calendarTable() { // the grid, and the quarters it draws, listed for the caption
const e = epochDay(...easter(YEAR)) - epochDay(MONTH, 0); // Easter as a day of MONTH: 28 in 2027
const feasts = { [e - 7]: 'Le Palme', [e]: 'Pasqua', [e + 1]: 'Pasquetta' };
const notes = { 19: 'S. Giuseppe', 20: 'Equinozio' }; // March 2027's, typed by hand
const first = weekday(1), days = epochDay(MONTH + 1, 1) - epochDay(MONTH, 1), moons = [];
const week = (names, isHeader) => names.flatMap((content) => [content, '']) // a day: moon, date
.map((content) => ({ content, isHeader }));
let m = { headerRowCount: 1, columnWidths: Array(7).fill([7, 18]).flat(), rows: [week(DAYS, true),
...Array.from({ length: Math.ceil((first + days) / 7) }, () => week(Array(7).fill('')))] };
for (let d = 1; d <= days; d++) {
const r = 1 + Math.floor((first + d - 1) / 7), c = 2 * weekday(d), sunday = c === 12;
// Sundays and feasts print in red. Every day has a second line, so all weeks are as tall.
const note = feasts[d] ? chip(feasts[d], 'festa') : notes[d] ? chip(notes[d], 'nota') : JOINER;
m = setCellContent(m, at(r, c + 1), `${sunday || feasts[d] ? chip(d, 'rosso') : d}\n${note}`);
const fill = d === e || d === e + 1 ? 'blush' : sunday ? 'cream' : null;
if (fill) for (const k of [c, c + 1]) m = setCellBackground(m, at(r, k), col(fill));
const midnight = epochDay(MONTH, d) - 1 / 24, q = quarter(midnight + 1); // 00.00 CET
if (q === quarter(midnight)) continue;
m = setCellImage(m, at(r, c), { resourceId: PHASES[q] }); // a quarter begins today
moons.push(`${PHASES[q].replace('-', ' ')} ${[1, 8, 11].includes(d) ? 'l’' : 'il '}${d}`);
}
for (let i = 0; i < 7; i++) m = mergeCells(m, span(0, 2 * i, 0, 2 * i + 1)); // one head a day
return { model: setCellBackground(m, at(0, 12), col('red')), moons: moons.join(', ') };
}
Day numbers from Date.UTC give every date its weekday, and the grid starts on Monday 1 March and runs to five weeks. Easter comes from the computus and the moon's quarters from the mean synodic month. Computed that way, a quarter can fall a day early or late, as the caption warns. The loop that draws each moon also writes its date into the caption. A cell's picture always sits above its text, which is why each day takes two columns, the moon's and the date's: a moon in the date's own cell would push the date down on four days only. Sundays and feasts are red through a chip style that changes only the colour, and the notes are chips at half the size. A day without a note gets a word joiner (U+2060) as its second line, which keeps the five weeks the same height.
#3 · Colour the matrix from pasted symbols
const FILLS = { '+': 'leaf', '−': 'blush', '': 'cream' }; // good, bad, no known effect
function companionTable(tsv) {
let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [26, ...Array(10).fill(15)] };
for (let r = 1; r < m.rows.length; r++) {
m = setAlignment(m, at(r, 0), 'left', 'middle');
for (let c = 1; c < m.rows[r].length; c++) {
const symbol = m.rows[r][c].content; // + and − stay printed, for greyscale copies
m = setCellContent(m, at(r, c), symbol && symbol !== '=' ? chip(symbol, 'segno') : '');
m = symbol === '=' // the diagonal pairs a crop with itself: its picture instead
? setCellImage(m, at(r, c), { resourceId: `veg-${r}`, width: 0.62 })
: setCellBackground(m, at(r, c), col(FILLS[symbol]));
m = setAlignment(m, at(r, c), 'center', 'middle');
}
}
for (let c = 1; c <= 10; c++) m = setAlignment(m, at(0, c), 'center');
return m;
}
The pairs are typed as TSV, one symbol per cell. In postext 1.4.1 parseTSV makes plain cells and leaves headerRowCount unset, so headerRowCount: 1 is added by hand to make the row of crop names the head. The columnWidths weights are millimetres of the 176 mm measure: 26 for the names, 15 for each crop. Each symbol names a palette entry, which setCellBackground lays in as the cell's fill; the + and − are printed as well, in a chip at 1.4 em, so the matrix still reads in a greyscale copy. The diagonal pairs a crop with itself, and there setCellImage puts the crop's drawing, at 62 % of the cell's inner width.
#4 · Key the colours with swatches
const resourceTypes = [ // 1.4.1 has English and Spanish ones (gotcha: resource-types-locale)
{ id: 'table', name: 'Tabella', shortLabel: 'Tab.', captionPrefix: 'Tabella',
captionStyle: { position: 'above' } },
{ id: 'calendar', name: 'Calendario', shortLabel: 'Cal.', captionPrefix: '' }, // no label
].map((t) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...t }));
const table = (id, typeId, caption, model, styleId, extra) => ({ id, typeId, kind: 'table',
caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...extra });
const foot = { position: 'bottom', span: 'page' }; // across both columns, at the page's foot
const calendar = calendarTable(); // its key names the quarters the grid draws
const resources = [...pictures, // never cited: the opener and the cells draw them by id
table('calendario', 'calendar', ':swatch{color="cream"} domeniche · :swatch{color="blush"} '
+ `Pasqua e Pasquetta · ${calendar.moons}, sul mese sinodico medio: un giorno prima o dopo `
+ 'è possibile. Per tradizione in crescente si semina ciò che fruttifica sopra terra, in '
+ 'calante le radici.', calendar.model, 'calendario', { placement: foot }),
table('consociazioni', 'table', 'Consociazioni tra dieci ortaggi', companionTable(companions),
'matrice', { placement: foot, note: ':swatch{color="leaf"} + favorevole · '
+ ':swatch{color="blush"} − da evitare · :swatch{color="cream"} nessun effetto noto. '
+ 'Indicazioni della tradizione orticola.' }),
// Each part of a split table repeats its caption, so the key goes there; the note ends the last.
table('semine', 'table', 'Semine al Nord e al Centro, in pianura e collina: '
+ ':swatch{color="ochre"} in semenzaio protetto · :swatch{color="green"} in piena terra · '
+ ':swatch{color="brown"} trapianto o messa a dimora', sowingChart(sowing), 'semine', {
placement: chartPlacement, note: 'Al Sud e lungo le coste le date si anticipano di '
+ 'due-quattro settimane; in montagna si ritardano.' }),
];
:swatch{color="leaf"} sets a square three quarters of the type size, filled with a palette entry and outlined in the text colour; these pages use it in running text, in two captions and in a note. The chart's key goes in its caption, because every part of a split table repeats the caption and the note is set under the last part only. The calendar is a resource type of its own with an empty caption prefix, and its caption opens on the key instead of a Tabella label. position: 'bottom' with span: 'page' sets the calendar and the matrix across both columns at the foot of their pages. Page 28 is the last page of text. On such a page 1.4.1 lifts a page-wide float to one line under the text, which is why the matrix ends 10 mm above the bottom margin.
#5 · A house style and a variant per table
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
headerFontSize: pt(7), bodyFontSize: pt(8.5), cellPadding: mm(1.2),
// 1.4.1 has continuation strings in English and Spanish only (gotcha: resource-types-locale).
continuedSuffix: '(segue)', continuesMarker: 'Continua alla pagina seguente' };
const tableStyles = [
{ id: 'calendario', bodyFontFamily: DISPLAY, bodyFontSize: pt(15), cellPadding: mm(1.4) },
{ id: 'matrice', rules: 'grid', borderColor: col('paper'), borderWidth: pt(2), // tiles
headerBackgroundEnabled: false, headerColor: col('ink'), headerFontSize: pt(6.8) },
{ id: 'semine', rules: 'grid', borderColor: col('paper'), borderWidth: pt(1),
bodyFontSize: pt(7.8), headerFontSize: pt(6.6), cellPadding: mm(1.1) },
];
tableStyle sets the house style: an ink head with paper-coloured labels in Commissioner, hairlines between the rows, and the Italian continuation strings, which 1.4.1 ships in English and Spanish only. Each named style states only what its table changes. The calendar sets its days in Gilda Display at 15 pt; the matrix drops the head's fill; the matrix and the chart draw grid rules in the paper's colour, 2 pt and 1 pt wide, which cut their fills into tiles.
#6 · The month opens on a drawing
const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: drawing, air under it, side bearing of the 84 pt M
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
...(size && { size }) }); // to: 'page', or '#id' of an element listed before
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
align: placement.anchor.edge.endsWith('right') ? 'right' : 'left',
overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// The drawing reserves nothing (gotcha: opener-image-no-reserve), so the text starts on the
// first grid line at least AIR under it.
const OPENER_H = pt(LEAD * Math.ceil((ART_H + AIR - PAGE.top) / (LEAD * 25.4 / 72)));
const opener = { enabled: true, minHeight: OPENER_H, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'campo',
placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(ART_H) }) },
text('kicker', '{attr.kicker}', LABEL, 8.5, 'red', pin('page', 'top-left', PAGE.inner, 12),
caps(8.5)), // page 1 is a recto: its inner margin is on the left
text('title', '{titleText}', DISPLAY, 84, 'ink', pin('#kicker', 'below', -BEARING, 1),
{ lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
text('proverb', '{attr.proverb}', TEXT, 12.5, 'ink',
pin('#title', 'below', BEARING, 1, { width: mm(140) }), { italic: true, lineHeight: 1.3 }),
text('source', '{attr.source}', LABEL, 7, 'ink', pin('#proverb', 'below', 0, 1.6), caps(7)),
] } };
The first-level heading is drawn from a slot of elements. The drawing is an image element at the head of the page, and the kicker, the proverb and its source come from the heading's attributes. An image element reserves no height in 1.4.1, and minHeight makes up for it: 92 mm of drawing plus 5 mm of air, less the 22 mm top margin, is 75 mm, which rounds up to 16 lines of 14 pt (79 mm). The first line of text sits 101 mm from the top of the page, 9 mm under the soil. The title moves 1.5 mm left, the side bearing of Gilda Display's M at 84 pt, and the M's serif lines up with the kicker; the proverb moves back by the same amount.
The whole recipe
// ═══ Postext Cookbook · Nº 035 · Garden almanac: calendar grid and landscape chart ═════ // https://postext.dev/en/cookbook/garden-almanac // Code: MIT · Text: original, in Italian (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells, setCellContent, setCellBackground, setCellImage, setAlignment, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'garden-almanac'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#262a22', paper: '#fbf8ef', // a green-black on unbleached paper red: '#a2372a', // Sundays and feasts, as almanacs print them; kickers and labels green: '#5b8a32', ochre: '#d39a2e', brown: '#7a5230', // sown outdoors, in a seedbed, planted leaf: '#bfdaa2', blush: '#f3cdbd', cream: '#e9ddc1', // good pairs, bad pairs, neutral pairs sky: '#cfe2e6', rule: '#cfc6b2', muted: '#6b6e63' }; // sky and work box; hairlines; notes // 1.4.1 design slots read the hex, not the id: col() writes both (gotcha: palette-skips-designs) const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) // the defaults' id .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Piazzolla', 'Gilda Display', 'Commissioner']; const PAGE = { w: 210, h: 280, top: 22, bottom: 21, inner: 18, outer: 16 }; // mm, mirrored const LEAD = 14; // pt: the body's leading and baseline grid const at = (row, column) => ({ row, col: column }); const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) }); const chip = (text, style) => `:chip[${text}]{style="${style}"}`; // #region answer: a crops × fortnights chart, turned to landscape on pages of its own // The chart's `placement` (#region resources): a quarter turn makes it a page-span float on // pages of its own, flush to the spine; rows past the page's width go on under a repeated head. const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split) const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic']; const SEASONS = [['INVERNO', 2], ['PRIMAVERA', 3], ['ESTATE', 3], ['AUTUNNO', 3], ['INVERNO', 1]]; const STATES = { S: 'ochre', C: 'green', T: 'brown' }; // seedbed, sown outdoors, planted out const fortnight = (key) => MONTHS.indexOf(key.slice(0, 3)) * 2 + Number(key[3]); // 'mar2' → 6 function sowingChart(data) { // 'Pomodoro: S feb2–mar2, T apr2–mag2'; a bare line is a family const row = (first, isHeader = false) => [first, ...Array(24).fill('')] .map((content) => ({ content, isHeader })); let m = { headerRowCount: 2, columnWidths: [30, ...Array(24).fill(8.5)], // mm, as weights rows: [row('', true), row('', true)] }; for (const line of data.trim().split('\n')) { const [name, plan] = line.split(': '); const r = m.rows.push(row(plan ? name : chip(name.toUpperCase(), 'famiglia'))) - 1; if (!plan) { m = mergeCells(m, span(r, 0, r, 24)); continue; } // a family heads its crops for (let f = 1; f <= 24; f++) { // every other month tinted, so a column reads down the page if ((f - 1) % 4 < 2) m = setCellBackground(m, at(r, f), col('cream')); } for (const step of plan.split(', ')) { // 'S feb2–mar2': one state over a run of fortnights const [state, range] = step.split(' '); const [from, to = from] = range.split('–').map(fortnight); for (let f = from; f <= to; f++) m = setCellBackground(m, at(r, f), col(STATES[state])); } } // Merged heads: 'Coltura' down both rows, each season over its months, each month over its // two fortnights. mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby). const merge = (r0, c0, r1, c1, content) => { // the head's text goes in its first cell m = mergeCells(setCellContent(m, at(r0, c0), content), span(r0, c0, r1, c1)); }; merge(0, 0, 1, 0, 'Coltura'); let c = 1; for (const [name, n] of SEASONS) { merge(0, c, 0, c + 2 * n - 1, name); c += 2 * n; } MONTHS.forEach((month, i) => merge(1, 2 * i + 1, 1, 2 * i + 2, month.toUpperCase())); for (const r of [0, 1]) for (let f = 1; f <= 24; f++) m = setAlignment(m, at(r, f), 'center'); return setCellBackground(m, at(1, fortnight('mar1')), col('red')); // this month's head } // #endregion // #region calendar: the month grid from real dates: weekdays, Easter, the moon's quarters const [YEAR, MONTH, DAY] = [2027, 3, 24 * 60 * 60 * 1000]; // DAY in ms const epochDay = (m, d) => Date.UTC(YEAR, m - 1, d) / DAY; // 1 January 1970 was a Thursday const weekday = (d) => (epochDay(MONTH, d) + 3) % 7; // 0 is Monday: Italian weeks start there function easter(y) { // the Gregorian computus (Meeus): [month, day] const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4); const g = Math.floor((8 * b + 13) / 25), h = (19 * a + b - d - g + 15) % 30; const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * (b % 4) + 2 * i - h - k) % 7; const n = h + l - 7 * Math.floor((a + 11 * h + 22 * l) / 451) + 114; return [Math.floor(n / 31), (n % 31) + 1]; } // The moon's age: mean synodic months since the new moon of 6 January 2000 at 18.14 UT. const [SYNODIC, NEW_MOON] = [29.530588853, Date.UTC(2000, 0, 6, 18, 14) / DAY]; const quarter = (day) => Math.floor((((day - NEW_MOON) % SYNODIC) / SYNODIC) * 4); // 0–3 const PHASES = ['luna-nuova', 'primo-quarto', 'luna-piena', 'ultimo-quarto']; const JOINER = '\u2060'; // a word joiner: the second line of a day without a note const DAYS = ['LUNEDÌ', 'MARTEDÌ', 'MERCOLEDÌ', 'GIOVEDÌ', 'VENERDÌ', 'SABATO', 'DOMENICA']; function calendarTable() { // the grid, and the quarters it draws, listed for the caption const e = epochDay(...easter(YEAR)) - epochDay(MONTH, 0); // Easter as a day of MONTH: 28 in 2027 const feasts = { [e - 7]: 'Le Palme', [e]: 'Pasqua', [e + 1]: 'Pasquetta' }; const notes = { 19: 'S. Giuseppe', 20: 'Equinozio' }; // March 2027's, typed by hand const first = weekday(1), days = epochDay(MONTH + 1, 1) - epochDay(MONTH, 1), moons = []; const week = (names, isHeader) => names.flatMap((content) => [content, '']) // a day: moon, date .map((content) => ({ content, isHeader })); let m = { headerRowCount: 1, columnWidths: Array(7).fill([7, 18]).flat(), rows: [week(DAYS, true), ...Array.from({ length: Math.ceil((first + days) / 7) }, () => week(Array(7).fill('')))] }; for (let d = 1; d <= days; d++) { const r = 1 + Math.floor((first + d - 1) / 7), c = 2 * weekday(d), sunday = c === 12; // Sundays and feasts print in red. Every day has a second line, so all weeks are as tall. const note = feasts[d] ? chip(feasts[d], 'festa') : notes[d] ? chip(notes[d], 'nota') : JOINER; m = setCellContent(m, at(r, c + 1), `${sunday || feasts[d] ? chip(d, 'rosso') : d}\n${note}`); const fill = d === e || d === e + 1 ? 'blush' : sunday ? 'cream' : null; if (fill) for (const k of [c, c + 1]) m = setCellBackground(m, at(r, k), col(fill)); const midnight = epochDay(MONTH, d) - 1 / 24, q = quarter(midnight + 1); // 00.00 CET if (q === quarter(midnight)) continue; m = setCellImage(m, at(r, c), { resourceId: PHASES[q] }); // a quarter begins today moons.push(`${PHASES[q].replace('-', ' ')} ${[1, 8, 11].includes(d) ? 'l’' : 'il '}${d}`); } for (let i = 0; i < 7; i++) m = mergeCells(m, span(0, 2 * i, 0, 2 * i + 1)); // one head a day return { model: setCellBackground(m, at(0, 12), col('red')), moons: moons.join(', ') }; } // #endregion // #region matrix: companion pairs pasted as TSV; each symbol becomes a palette fill const FILLS = { '+': 'leaf', '−': 'blush', '': 'cream' }; // good, bad, no known effect function companionTable(tsv) { let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [26, ...Array(10).fill(15)] }; for (let r = 1; r < m.rows.length; r++) { m = setAlignment(m, at(r, 0), 'left', 'middle'); for (let c = 1; c < m.rows[r].length; c++) { const symbol = m.rows[r][c].content; // + and − stay printed, for greyscale copies m = setCellContent(m, at(r, c), symbol && symbol !== '=' ? chip(symbol, 'segno') : ''); m = symbol === '=' // the diagonal pairs a crop with itself: its picture instead ? setCellImage(m, at(r, c), { resourceId: `veg-${r}`, width: 0.62 }) : setCellBackground(m, at(r, c), col(FILLS[symbol])); m = setAlignment(m, at(r, c), 'center', 'middle'); } } for (let c = 1; c <= 10; c++) m = setAlignment(m, at(0, c), 'center'); return m; } // #endregion // #region styles: one house table style and a named variant for each of the three tables const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(7), bodyFontSize: pt(8.5), cellPadding: mm(1.2), // 1.4.1 has continuation strings in English and Spanish only (gotcha: resource-types-locale). continuedSuffix: '(segue)', continuesMarker: 'Continua alla pagina seguente' }; const tableStyles = [ { id: 'calendario', bodyFontFamily: DISPLAY, bodyFontSize: pt(15), cellPadding: mm(1.4) }, { id: 'matrice', rules: 'grid', borderColor: col('paper'), borderWidth: pt(2), // tiles headerBackgroundEnabled: false, headerColor: col('ink'), headerFontSize: pt(6.8) }, { id: 'semine', rules: 'grid', borderColor: col('paper'), borderWidth: pt(1), bodyFontSize: pt(7.8), headerFontSize: pt(6.6), cellPadding: mm(1.1) }, ]; // #endregion // #region opener: the month on a drawing, with its proverb from the heading's attributes const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: drawing, air under it, side bearing of the 84 pt M const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); // to: 'page', or '#id' of an element listed before const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default) const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) }); // The drawing reserves nothing (gotcha: opener-image-no-reserve), so the text starts on the // first grid line at least AIR under it. const OPENER_H = pt(LEAD * Math.ceil((ART_H + AIR - PAGE.top) / (LEAD * 25.4 / 72))); const opener = { enabled: true, minHeight: OPENER_H, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'campo', placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(ART_H) }) }, text('kicker', '{attr.kicker}', LABEL, 8.5, 'red', pin('page', 'top-left', PAGE.inner, 12), caps(8.5)), // page 1 is a recto: its inner margin is on the left text('title', '{titleText}', DISPLAY, 84, 'ink', pin('#kicker', 'below', -BEARING, 1), { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple) text('proverb', '{attr.proverb}', TEXT, 12.5, 'ink', pin('#title', 'below', BEARING, 1, { width: mm(140) }), { italic: true, lineHeight: 1.3 }), text('source', '{attr.source}', LABEL, 7, 'ink', pin('#proverb', 'below', 0, 1.6), caps(7)), ] } }; // #endregion const head = (id, content, parity, x, extra) => text(id, content, LABEL, 7.5, 'muted', pin('page', x > 0 ? 'top-left' : 'top-right', x, 12), { ...caps(7.5), parity, pages: 'body', ...extra }); // body pages only: the opener has its drawing, and a folio at the foot const folio = { fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 400, letterSpacing: pt(0), color: col('red') }; const header = { elements: [head('verso-folio', '{pageNumber}', 'even', PAGE.outer, folio), head('verso-title', '{title}', 'even', PAGE.outer + 10), head('recto-title', '{chapterTitle}', 'odd', -(PAGE.outer + 10)), head('recto-folio', '{pageNumber}', 'odd', -PAGE.outer, folio)] }; const footer = { elements: [text('drop-folio', '{pageNumber}', DISPLAY, 11, 'red', pin('page', 'bottom', 0, -12), { pages: 'opener', align: 'center' })] }; // the opener's folio const bare = (id, extra) => ({ id, backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), ...extra }); // a chip that is only a change of face, size or colour const note = (color) => ({ fontFamily: TEXT, fontSize: em(0.5), italic: true, color: col(color) }); const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) locale: 'it', resourceTypes, colorPalette, tableStyle, tableStyles, header, footer, page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'), pageNumbering: { startAt: 27 }, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, // March opens on p. 27 layout: { layoutType: 'double', gutterWidth: mm(7) }, bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'), boldFontWeight: 600, boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), referenceBold: false, firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 }, // from 0.6 and 2 headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener, marginBottom: pt(0) }, { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, chipStyles: [bare('rosso', { color: col('red') }), bare('nota', note('muted')), bare('festa', note('red')), bare('segno', { fontFamily: LABEL, fontSize: em(1.4), bold: true }), bare('famiglia', { fontFamily: LABEL, fontSize: em(0.85), bold: true, color: col('red') })], calloutStyles: [ { id: 'lavori', title: 'Lavori del mese', span: 'page', background: col('sky'), padding: { top: mm(3.5), right: mm(5), bottom: mm(4), left: mm(5) }, columnGap: mm(7), titleStyle: { fontFamily: LABEL, fontSize: pt(8), ...caps(8), color: col('red') }, body: { fontSize: pt(9.2), lineHeight: pt(13) } }, { id: 'colonna', backgroundEnabled: false, padding: { top: mm(0), right: mm(0), // no frame bottom: mm(0), left: mm(0) }, lists: { gap: mm(2.2), itemSpacing: pt(2) }, titleStyle: { fontFamily: TEXT, fontSize: pt(9.2), italic: true, fontWeight: 400, color: col('ink') } }, ], unorderedLists: { bulletChar: '–', color: col('red'), fontWeight: 400 }, captionStyle: { fontSize: pt(8.5), labelColor: col('red'), gap: mm(2), note: { fontSize: pt(7.5), color: col('muted') } }, paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), textAlign: 'left', color: col('muted'), firstLineIndent: pt(0), marginTop: pt(LEAD) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 36 lines · content.en.md
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`SolanaceeMarkdown sample · 46 lines · content.semine.en.md
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 PatataMarkdown sample · 10 lines · content.consociazioni.en.md
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 · core, fonts, viewer, images: the same in every recipe · 270 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Turn the chart clockwise
The head then faces the right edge of the page, and the reader turns the book the other way.
-const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
+const chartPlacement = { rotate: 'cw' }; // a float, never 'here' (gotcha: here-table-no-split)Pitfalls
Pitfall
A 'here' table never splits
Only floated tables split across columns and pages; a table placed 'here' moves whole. Let a long table float, or keep inline tables short. Tables across pages →
Pitfall
Localise Figure/Table with defaultResourceTypes(locale)
The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes. Figure and Table in your language →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
A design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
Pitfall
Design text overflow defaults to 'ellipsis-end'
A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →
Pitfall
A config is cached by identity: build a fresh object
The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
- A picture in a table cell is always drawn above the cell's text and aligned like it. To set an icon beside a number, give the icon a column of its own, as the calendar does with the moon.
- The note under a split table appears on its last part only, so a key that every part needs belongs in the caption.
Credits
- Recipe
- Ignacio Ferro
- Text
- The Italian text of the March pages, with the table captions, the notes and the colophon, written for this recipe · Ignacio Ferro · CC BY 4.0
- The proverb “Marzo asciutto, aprile bagnato, beato il villan che ha seminato”, a traditional Italian saying · Proverbio contadino · public domain
- Fonts
- Piazzolla (SIL OFL 1.1) · Gilda Display (SIL OFL 1.1) · Commissioner (SIL OFL 1.1)


