En bref
Les pages de mars d’un almanach de jardinier, avec un calendrier du mois et des tableaux colorés. Elles montrent comment construire des tableaux à partir de données et faire pivoter un tableau trop large pour la page.
Ce que vous allez composer
Les pages de mars d’un almanach de jardinier écrit en italien. La page 27 s’ouvre sur une aquarelle de semis dans une terre sombre sous un ciel pâle, avec le nom du mois en 84 pt et un proverbe paysan. Sous deux colonnes de texte vient le calendrier du mois, que le pen calcule à partir de dates : les dimanches en rouge sur une colonne crème, le dimanche et le lundi de Pâques sur du rose, et les quatre quartiers de lune dessinés à côté de leurs jours. La page 28 liste les travaux du mois dans un encadré sur deux colonnes et colore en vert, rose ou crème une matrice d’associations de cultures de dix sur dix, avec le dessin de chaque culture sur la diagonale. Les dates de semis de l’année, 38 cultures par quinzaine, sont plus larges que la page : le tableau tourne donc d’un quart sur les pages 29 et 30 et répète son en-tête sur la seconde.
Cette recette répond à
- Comment composer un tableau large à l’italienne sur sa propre page ?
- Comment faire un tableau avec rangées d’en-tête, cellules fusionnées, largeurs de colonne et alignement par cellule ?
- Comment styler plusieurs tableaux différemment (fonds, cellules zébrées, cadres arrondis) dans un même document ?
- Comment mettre des images ou des icônes dans les cellules d’un tableau ?
- Comment ajouter des pastilles de couleur de légende au texte, aux légendes ou aux notes de tableau ?
La réponse courte
// 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
}
Ingrédients
- Fonctionnalités
- Tableaux et figures à l’italienneTableaux à partir de donnéesFonds de celluleTableaux sur plusieurs pagesStyles de tableau nommésStyle de tableauImages dans les cellules de tableauPastilles de couleurPastilles dans le texteColonnes dans un encadré ou dans le texteOuvertures composéesBandeau de chapitre sur toute la largeurAttributs de titrePalette de couleurs sémantiqueMarges en miroirLignes de source et de créditFigure et Tableau dans votre langueCésure et langue du documentFigures et tableaux comme ressourcesPages sur un canvas
- Utilise aussi
- EncadrésDes citations qui placent les figuresEncadrés imbriquésCouleur du papierTitres courants selon le rôle de la pageStyles de paragrapheTypes de ressource personnalisésPages liminaires en chiffres romainsEspace vertical explicite
- Typographie
- Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1)
- Ressources
campo-1680.jpg- The opener’s field of seedlings in March, a watercolour (Generated With Diffusion Models, original)
- The moon’s quarters and the ten crops, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)
Préparation
#1 · Faire pivoter le tableau et le laisser continuer
Le code est la réponse courte plus haut. rotate: 'ccw' fait du tableau un flottant sur toute la largeur de la page, sur des pages dédiées (Placement), avec son en-tête vers le bord gauche de la page, et il se cale contre le dos sur le recto comme sur le verso. Il est mis en page le long de la hauteur du bloc de texte, 227 de ses 237 mm : les 47 lignes de grille entières de 14 pt qui y tiennent, moins une ligne pour l’écart du flottant. Ses rangées s’empilent sur les 176 mm de largeur et, quand elles la dépassent, le tableau est coupé entre deux rangées et continue au verso sous ses deux rangées d’en-tête et sa légende, qui se termine par (segue) (Tableaux plus hauts que la page). Seul un flottant peut pivoter : en position: 'here', la rotation est ignorée, et le tableau serait coupé debout, entre deux rangées, là où sa colonne se termine. L’en-tête est fusionné de trois façons : Coltura sur les deux rangées d’en-tête, chaque saison au-dessus de ses mois, et chaque mois au-dessus de ses deux quinzaines.
#2 · Un calendrier calculé à partir de 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(', ') };
}
Les numéros de jour tirés de Date.UTC donnent à chaque date son jour de la semaine, et la grille commence le lundi 1er mars et s’étend sur cinq semaines. Pâques vient du comput et les quartiers de lune du mois synodique moyen. Calculé ainsi, un quartier peut tomber un jour trop tôt ou trop tard, comme l’indique la légende. La boucle qui dessine chaque lune écrit aussi sa date dans la légende. L’image d’une cellule se trouve toujours au-dessus de son texte, et c’est pourquoi chaque jour prend deux colonnes, celle de la lune et celle de la date : une lune dans la cellule même de la date ne repousserait la date vers le bas que sur quatre jours. Les dimanches et les fêtes sont en rouge grâce à un style de pastille qui ne change que la couleur, et les notes sont des pastilles à mi-corps. Un jour sans note reçoit un gluon de mots (U+2060) comme deuxième ligne, ce qui garde la même hauteur aux cinq semaines.
#3 · Colorer la matrice à partir de symboles collés
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;
}
Les paires sont saisies en TSV, un symbole par cellule. parseTSV(tsv, { headerRows: 1 }) fait de la rangée des noms de cultures l’en-tête. Les poids de columnWidths sont des millimètres de la justification de 176 mm : 26 pour les noms, 15 pour chaque culture. Chaque symbole nomme une entrée de la palette, que setCellBackground pose comme fond de la cellule ; les + et − sont aussi imprimés, dans une pastille à 1,4 em, pour que la matrice reste lisible dans une copie en niveaux de gris. La diagonale associe une culture à elle-même, et c’est là que setCellImage place le dessin de la culture, à 62 % de la largeur intérieure de la cellule.
#4 · Légender les couleurs avec des pastilles
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"} compose un carré des trois quarts du corps, rempli d’une entrée de la palette et cerné de la couleur du texte ; ces pages l’emploient dans le texte courant, dans deux légendes et dans une note. La clé du tableau des semis va dans sa légende, parce que chaque partie d’un tableau coupé répète la légende et que la note ne se compose que sous la dernière partie. Le calendrier est un type de ressource à part entière avec un préfixe de légende vide, et sa légende s’ouvre sur la clé au lieu d’une étiquette Tabella. position: 'bottom' avec span: 'page' place le calendrier et la matrice sur les deux colonnes au pied de leur page. La page 28 est la dernière page de texte. Sur la page de fin d’un chapitre, un flottant sur toute la largeur remonte jusqu’à une ligne sous le texte, et c’est pourquoi la matrice se termine 10 mm au-dessus de la marge du bas ; layout.hugClosingFloats: false la laisserait au pied.
#5 · Un style maison et une variante par tableau
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 définit le style maison : un en-tête à l’encre avec des libellés couleur papier en Commissioner, des filets fins entre les rangées, et la ligne qui termine chaque partie d’un tableau coupé. Un document italien termine une partie par Continua et ajoute (segue) à la légende de la partie suivante ; l’almanach écrit la première en toutes lettres, Continua alla pagina seguente. Chaque style nommé n’indique que ce que son tableau change. Le calendrier compose ses jours en Gilda Display de 15 pt ; la matrice retire le fond de l’en-tête ; la matrice et le tableau des semis tracent des filets de grille couleur papier, de 2 pt et 1 pt, qui découpent leurs fonds en carreaux.
#6 · Le mois s’ouvre sur une peinture
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)),
] } };
Le titre de niveau 1 est dessiné à partir d’un emplacement d’éléments. L’aquarelle est un élément image en haut de la page, et le surtitre, le proverbe et sa source viennent des attributs du titre. L’ouverture réserve la place jusqu’au pied de la peinture, et minHeight est un plancher placé un peu plus bas : 92 mm de peinture plus 5 mm d’air, moins les 22 mm de marge du haut, font 75 mm, arrondis à 16 lignes de 14 pt (79 mm). La première ligne de texte se trouve à 101 mm du haut de la page, 9 mm sous la terre. Le titre se décale de 1,5 mm vers la gauche, l’approche latérale du M de Gilda Display à 84 pt, et l’empattement du M s’aligne sur le surtitre ; le proverbe revient d’autant.
La recette complète
// ═══ 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`---Exemple Markdown · 38 lignes · 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. :::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`SolanaceeExemple Markdown · 46 lignes · 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 PatataExemple Markdown · 10 lignes · content.consociazioni.en.md
Pomodoro = + + + + + − − 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 : identique dans toutes les recettes · 270 lignes
// ─── 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Faire pivoter le tableau dans le sens horaire
L’en-tête fait alors face au bord droit de la page, et le lecteur tourne le livre dans l’autre sens.
-const chartPlacement = { rotate: 'ccw' }; // a float: 'here' ignores the turn
+const chartPlacement = { rotate: 'cw' }; // a float: 'here' ignores the turnErreurs fréquentes
- Une image dans une cellule de tableau est toujours dessinée au-dessus du texte de la cellule et alignée comme lui. Pour placer une icône à côté d’un nombre, donnez à l’icône sa propre colonne, comme le fait le calendrier avec la lune.
- La note sous un tableau coupé n’apparaît que sur sa dernière partie : une clé dont chaque partie a besoin va dans la légende.
Crédits
- Recette
- Ignacio Ferro
- Texte
- 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 · domaine public
- Images
- The opener’s field of seedlings in March, a watercolour · Generated With Diffusion Models · original
- The moon’s quarters and the ten crops, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- Polices
- Piazzolla (SIL OFL 1.1) · Gilda Display (SIL OFL 1.1) · Commissioner (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


