Em poucas palavras
As páginas de março de um almanaque de horta, com um calendário do mês e tabelas coloridas. Mostra como montar tabelas a partir de dados e virar de lado uma tabela larga demais para a página.
O que você vai compor
As páginas de março de um almanaque de horta escrito em italiano. A página 27 abre com uma aquarela de mudas em terra escura sob um céu claro, com o nome do mês a 84 pt e um provérbio do campo. Embaixo de duas colunas de texto vem o calendário do mês, que o pen calcula a partir de datas: domingos em vermelho numa coluna creme, o domingo e a segunda-feira de Páscoa em rosa e as quatro fases da lua desenhadas ao lado dos seus dias. A página 28 lista as tarefas do mês num boxe de duas colunas e pinta de verde, rosa ou creme uma matriz de consórcio de culturas de dez por dez, com o desenho de cada cultura na diagonal. As datas de semeadura do ano, 38 culturas por quinzena, são mais largas que a página, então a tabela gira um quarto de volta nas páginas 29 e 30 e repete o cabeçalho na segunda.
Esta receita responde a
- Como componho uma tabela larga em paisagem, em uma página só para ela?
- Como faço uma tabela com linhas de cabeçalho, células mescladas, larguras de coluna e alinhamento por célula?
- Como dou estilos diferentes a várias tabelas (preenchimentos, células zebradas, molduras arredondadas) em um mesmo documento?
- Como coloco imagens ou ícones dentro das células de uma tabela?
- Como acrescento amostras de cor, como chave de cores, ao texto, às legendas ou às notas de tabela?
A resposta curta
// The chart's `placement` (#region resources): a quarter turn makes it a page-span float on
// pages of its own, flush to the spine; rows past the page's width go on under a repeated head.
const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const SEASONS = [['INVERNO', 2], ['PRIMAVERA', 3], ['ESTATE', 3], ['AUTUNNO', 3], ['INVERNO', 1]];
const STATES = { S: 'ochre', C: 'green', T: 'brown' }; // seedbed, sown outdoors, planted out
const fortnight = (key) => MONTHS.indexOf(key.slice(0, 3)) * 2 + Number(key[3]); // 'mar2' → 6
function sowingChart(data) { // 'Pomodoro: S feb2–mar2, T apr2–mag2'; a bare line is a family
const row = (first, isHeader = false) => [first, ...Array(24).fill('')]
.map((content) => ({ content, isHeader }));
let m = { headerRowCount: 2, columnWidths: [30, ...Array(24).fill(8.5)], // mm, as weights
rows: [row('', true), row('', true)] };
for (const line of data.trim().split('\n')) {
const [name, plan] = line.split(': ');
const r = m.rows.push(row(plan ? name : chip(name.toUpperCase(), 'famiglia'))) - 1;
if (!plan) { m = mergeCells(m, span(r, 0, r, 24)); continue; } // a family heads its crops
for (let f = 1; f <= 24; f++) { // every other month tinted, so a column reads down the page
if ((f - 1) % 4 < 2) m = setCellBackground(m, at(r, f), col('cream'));
}
for (const step of plan.split(', ')) { // 'S feb2–mar2': one state over a run of fortnights
const [state, range] = step.split(' ');
const [from, to = from] = range.split('–').map(fortnight);
for (let f = from; f <= to; f++) m = setCellBackground(m, at(r, f), col(STATES[state]));
}
}
// Merged heads: 'Coltura' down both rows, each season over its months, each month over its
// two fortnights. mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby).
const merge = (r0, c0, r1, c1, content) => { // the head's text goes in its first cell
m = mergeCells(setCellContent(m, at(r0, c0), content), span(r0, c0, r1, c1));
};
merge(0, 0, 1, 0, 'Coltura');
let c = 1;
for (const [name, n] of SEASONS) { merge(0, c, 0, c + 2 * n - 1, name); c += 2 * n; }
MONTHS.forEach((month, i) => merge(1, 2 * i + 1, 1, 2 * i + 2, month.toUpperCase()));
for (const r of [0, 1]) for (let f = 1; f <= 24; f++) m = setAlignment(m, at(r, f), 'center');
return setCellBackground(m, at(1, fortnight('mar1')), col('red')); // this month's head
}
Ingredientes
- Funcionalidades
- Tabelas e figuras em paisagemTabelas a partir de dadosPreenchimento de célulasTabelas que passam de páginaEstilos de tabela com nomeEstilo de tabelaImagens nas células de tabelaAmostras de corChips no textoColunas dentro de um boxeAberturas desenhadasFaixa de capítulo em largura totalAtributos de títuloPaleta de cores semânticaMargens espelhadasLinhas de fonte e créditoFigura e Tabela no seu idiomaHifenização e idioma do documentoFiguras e tabelas como recursosPáginas em um canvas
- Também usa
- BoxesCitações que posicionam as figurasBoxes aninhadosCor do papelCabeços por tipo de páginaEstilos de parágrafoTipos de recurso personalizadosElementos pré-textuais em romanos
- Tipografia
- Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1)
- Materiais
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)
Preparo
#1 · Gire a tabela e deixe que ela continue
O código está na resposta curta, mais acima. rotate: 'ccw' faz da tabela uma flutuante com span de página, em páginas próprias (Posição), com o cabeçalho voltado para a borda esquerda da página, e ela fica encostada na lombada tanto na página ímpar quanto na par. Ela é diagramada ao longo da altura da mancha, 227 dos seus 237 mm: as 47 linhas inteiras da grade de 14 pt que cabem, menos uma linha para o espaço da flutuante. As linhas da tabela se empilham na largura de 176 mm e, quando passam dela, a tabela é cortada entre duas linhas e continua na página seguinte sob as suas duas linhas de cabeçalho e a legenda, que termina em (segue) (Tabelas mais altas que a página). Só uma flutuante pode girar ou se dividir: com position: 'here' a rotação é ignorada e a tabela nunca se divide. O cabeçalho tem três tipos de células mescladas: Coltura ocupa as duas linhas de cabeçalho, cada estação fica sobre os seus meses e cada mês sobre as suas duas quinzenas.
#2 · Um calendário calculado a partir de datas
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(', ') };
}
Os números de dia de Date.UTC dão a cada data o seu dia da semana, e a grade começa na segunda-feira, 1º de março, e ocupa cinco semanas. A Páscoa vem do cômputo eclesiástico, e as fases da lua, do mês sinódico médio. Calculada assim, uma fase pode cair um dia antes ou depois, como a legenda avisa. O laço que desenha cada lua também escreve a data dela na legenda. A imagem de uma célula fica sempre acima do texto, por isso cada dia ocupa duas colunas, a da lua e a da data: uma lua na própria célula da data empurraria a data para baixo em apenas quatro dias. Domingos e feriados saem em vermelho por meio de um estilo de chip que muda só a cor, e as notas são chips na metade do tamanho. Um dia sem nota recebe um word joiner (U+2060) como segunda linha, o que mantém as cinco semanas com a mesma altura.
#3 · Pinte a matriz a partir de símbolos colados
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;
}
Os pares são digitados como TSV, um símbolo por célula. No postext 1.4.1, parseTSV cria células simples e deixa headerRowCount sem valor, então headerRowCount: 1 é acrescentado à mão para que a linha com os nomes das culturas seja o cabeçalho. Os pesos de columnWidths são milímetros da medida de 176 mm: 26 para os nomes, 15 para cada cultura. Cada símbolo nomeia uma entrada da paleta, que setCellBackground põe como preenchimento da célula; o + e o − também são impressos, num chip a 1,4 em, para que a matriz continue legível numa cópia em tons de cinza. A diagonal junta cada cultura com ela mesma, e ali setCellImage põe o desenho da cultura, a 62 % da largura interna da célula.
#4 · Faça a legenda das cores com amostras
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"} compõe um quadrado de três quartos do corpo, preenchido com uma entrada da paleta e contornado na cor do texto; estas páginas o usam no texto corrido, em duas legendas e numa nota. A chave de cores da tabela de semeadura vai na legenda, porque cada parte de uma tabela dividida repete a legenda, e a nota sai só embaixo da última parte. O calendário é um tipo de recurso próprio com prefixo de legenda vazio, e a legenda dele começa pela chave de cores em vez de um rótulo Tabella. position: 'bottom' com span: 'page' põe o calendário e a matriz na largura das duas colunas, no pé das suas páginas. A página 28 é a última página de texto. Numa página assim, a 1.4.1 sobe uma flutuante de largura total até uma linha abaixo do texto, por isso a matriz termina 10 mm acima da margem inferior.
#5 · Um estilo da casa e uma variante por tabela
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 define o estilo da casa: um cabeçalho cor de tinta com rótulos cor de papel em Commissioner, fios finos entre as linhas e os textos de continuação em italiano, que a 1.4.1 só traz em inglês e espanhol. Cada estilo nomeado declara só o que a sua tabela muda. O calendário compõe os dias em Gilda Display a 15 pt; a matriz tira o preenchimento do cabeçalho; a matriz e a tabela de semeadura desenham fios de grade na cor do papel, com 2 pt e 1 pt, que cortam os preenchimentos em casas.
#6 · O mês abre com uma pintura
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 }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// The painting 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)),
] } };
O título de nível 1 é desenhado a partir de um encaixe de elementos. A aquarela é um elemento de imagem no alto da página, e o chapéu, o provérbio e a fonte dele vêm dos atributos do título. Na 1.4.1 um elemento de imagem não reserva altura, e minHeight compensa isso: 92 mm de pintura mais 5 mm de respiro, menos os 22 mm da margem superior, dão 75 mm, que se arredondam para cima em 16 linhas de 14 pt (79 mm). A primeira linha de texto fica a 101 mm do alto da página, 9 mm abaixo da terra. O título se desloca 1,5 mm para a esquerda, o espaço lateral do M da Gilda Display a 84 pt, e a serifa do M se alinha com o chapéu; o provérbio volta a mesma medida.
A receita completa
// ═══ 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.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 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 }); // not '…' at the edge (gotcha: overflow-ellipsis-default) const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) }); // The painting 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`---Amostra em Markdown · 36 linhas · 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 icone originali, CC BY 4.0 · Acquerello generato. :::`; // the month's text, in Italian const sowing = String.raw`SolanaceeAmostra em Markdown · 46 linhas · 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 PatataAmostra em Markdown · 10 linhas · 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 = [ // 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: igual em todas as receitas · 271 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** 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, '-'); } /** 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 v1 ── 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. */ 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 ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Gire a tabela no sentido horário
O cabeçalho passa a ficar voltado para a borda direita da página, e o leitor gira o livro para o outro lado.
-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)Erros comuns
Erro comum
Uma tabela 'here' nunca se divide
Só as tabelas flutuantes se dividem entre colunas e páginas; uma tabela posicionada como 'here' se move inteira. Deixe as tabelas longas flutuarem ou mantenha curtas as tabelas em linha. Tabelas que passam de página →
Erro comum
Traduza Figura e Tabela com defaultResourceTypes(locale)
O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes. Figura e Tabela no seu idioma →
Erro comum
As imagens de uma abertura nunca contam para a altura que ela reserva
No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
O lineHeight de um texto de design é um múltiplo, nunca uma medida
Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →
Erro comum
O excesso de texto de design é 'ellipsis-end' por padrão
Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
- Uma imagem numa célula de tabela é sempre desenhada acima do texto da célula e com o mesmo alinhamento dele. Para pôr um ícone ao lado de um número, dê ao ícone uma coluna própria, como o calendário faz com a lua.
- A nota embaixo de uma tabela dividida aparece só na última parte, então uma chave de cores de que todas as partes precisam deve ir na legenda.
Créditos
- Receita
- Ignacio Ferro
- Texto
- 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 · domínio público
- Imagens
- 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
- Fontes
- Piazzolla (SIL OFL 1.1) · Gilda Display (SIL OFL 1.1) · Commissioner (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


