かんたんな説明
家庭菜園の暦の3月のページです。月のカレンダーと色分けした表があります。データから表を作る方法と、ページに収まらない幅の表を横に倒す方法を示します。
できあがり
イタリア語で書かれた家庭菜園の暦の、3月のページです。27ページは、淡い空の下で黒い土に並ぶ苗の水彩画で始まり、84ptの月名と農村のことわざが添えられます。2段組みの本文の下には、その月のカレンダーが入ります。これはサンプルのコードが日付から計算したものです。日曜はクリーム色の列に赤字で、復活祭の日曜と月曜はピンクの上に置き、月の4つの相はその日の横に描きます。28ページはその月の作業を2段組みの囲みに並べ、10×10のコンパニオンプランツの表を緑、ピンク、クリーム色に塗り分け、対角線上に各作物の絵を置きます。1年の種まき時期を38作物、半月刻みで示す表はページより幅が広いため、90度回して29ページと30ページに組み、2ページ目で表頭を繰り返します。
このレシピが答える質問
- 幅の広い表を、専用のページに横向きで組むには?
- 見出し行、結合セル、列幅、セルごとのそろえを備えた表を作るには?
- 1つの文書の中で、複数の表を異なるスタイル(塗り、縞模様のセル、角丸の枠)にするには?
- 表のセルに画像やアイコンを入れるには?
- 本文、キャプション、表の注に色見本(凡例の色)を加えるには?
手短な答え
// 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
}
材料
- 機能
- 横向きの表と図データから作る表セルの塗りページをまたぐ表名前付きの表スタイル表スタイル表のセル内の画像色見本インラインのチップ囲みの中の段組みデザインした章扉段抜きの章見出し帯見出しの属性セマンティックカラーパレット見開きの余白出典とクレジット「図」と「表」を文書の言語でハイフネーションと文書の言語リソースとしての図と表キャンバス上のページ
- 種類
- Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1)
- 素材
campo-1680.jpg- The opener’s field of seedlings in March, a watercolour (Generated With Diffusion Models, オリジナル)
- The moon’s quarters and the ten crops, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)
作り方
#1 · 表を回し、次のページへ続ける
コードは上の手短な答えにあります。rotate: 'ccw'は、表を専用のページに置く全幅のフロートにします(配置)。表頭はページの左端を向き、奇数ページでも偶数ページでも背の側に寄ります。表は版面の高さ方向に組まれ、237mmのうち227mmを使います。収まる14ptのグリッド行47行分から、フロートの間隔の1行を引いた長さです。行は176mmの幅を横切って積み重なり、幅を超えると表は2つの行のあいだで切られ、2行の表頭とキャプションを付けて次のページへ続きます。キャプションの末尾には(segue)が付きます(ページより高い表)。回転も分割もフロートにしかできません。position: 'here'では回転は無視され、表は分割されません。表頭のセルは3通りに結合しています。Colturaは表頭の2行を縦に通し、各季節はその月の上に、各月はその2つの半月の上にまたがります。
#2 · 日付から計算するカレンダー
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(', ') };
}
Date.UTCから得る通日で各日付の曜日が決まり、グリッドは3月1日の月曜から始まって5週分続きます。復活祭はコンプトゥス(復活祭の日付計算)から、月の相は平均朔望月から求めます。この方法では相が1日前後にずれることがあり、キャプションでもそう断っています。月を描くループは、その日付をキャプションにも書き込みます。セルの画像は常にテキストの上に置かれるため、各日は月の列と日付の列の2列を取ります。月を日付と同じセルに入れると、4日だけ日付が下に押されてしまいます。日曜と祝日は色だけを変えるチップスタイルで赤にし、注記は半分の大きさのチップにします。注記のない日には2行目にワードジョイナー(U+2060)を入れ、5週の高さをそろえます。
#3 · 貼り付けた記号から表を塗る
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;
}
作物の組み合わせはTSVで、セルごとに記号を1つ入力します。postext 1.4.1のparseTSVは素のセルを作り、headerRowCountは設定しません。そこで作物名の行を表頭にするため、headerRowCount: 1を手で加えます。columnWidthsの重みは176mmの行長に対するミリメートルで、名前に26、各作物に15です。各記号は色見本の項目を指し、setCellBackgroundがそれをセルの塗りにします。+と−も1.4emのチップで刷るので、グレースケールで複写しても表は読めます。対角線上は作物とそれ自身の組み合わせなので、そこにはsetCellImageが作物の絵を、セルの内側の幅の62%で置きます。
#4 · 色の凡例を色見本で示す
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"}は、文字サイズの4分の3の正方形を色見本の色で塗り、本文の色で縁取ります。これらのページでは、本文中、2つのキャプション、1つの注で使っています。表の凡例はキャプションに入れます。分割した表は各部分でキャプションを繰り返しますが、注は最後の部分の下にしか組まれないからです。カレンダーは独自のリソースの種類で、キャプションの接頭辞は空です。そのキャプションはTabellaのラベルではなく凡例で始まります。position: 'bottom'とspan: 'page'で、カレンダーと表をそれぞれのページの下端に2段にまたがって組みます。28ページは本文の最後のページです。1.4.1はそうしたページで全幅のフロートを本文の1行下まで引き上げるため、表は地の余白より10mm上で終わります。
#5 · 共通のスタイルと表ごとの変種
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で共通のスタイルを決めます。墨色の表頭にCommissionerの紙色のラベル、行のあいだにヘアライン、そしてイタリア語の続き表示の文字列です。1.4.1には英語とスペイン語の文字列しか入っていません。名前付きの各スタイルには、その表が変える点だけを書きます。カレンダーは日付をGilda Displayの15ptで組み、コンパニオンプランツの表は表頭の塗りを外します。この表と種まき表は、紙の色で幅2ptと1ptの罫を引き、塗りをタイルに切り分けます。
#6 · 月は1枚の絵で始まる
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)),
] } };
レベル1の見出しは要素のスロットから描きます。水彩画はページの頭に置いた画像要素で、キッカー、ことわざ、その出典は見出しの属性から取ります。1.4.1では画像要素は高さを確保しないので、minHeightで補います。絵の92mmに余白5mmを足し、天の余白22mmを引くと75mmで、14ptの行16行(79mm)に切り上がります。本文の1行目はページの上端から101mm、土の9mm下に来ます。題は1.5mm左へずらします。これは84ptのGilda DisplayのMのサイドベアリングで、Mのセリフがキッカーとそろいます。ことわざは同じ量だけ戻します。
レシピの全体
// ═══ 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`---Markdownの見本 · 36行 · 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`SolanaceeMarkdownの見本 · 46行 · content.semine.en.md
Pomodoro: S feb2–mar2, T apr2–mag2 Peperone: S feb1–mar1, T mag1–mag2 Melanzana: S feb1–mar1, T mag1–mag2 Patata: T mar1–apr1 Cucurbitacee Zucchina: S mar2–apr1, T apr2–mag1, C mag2–giu2 Cetriolo: S mar2–apr1, T mag1–mag2, C giu1–giu2 Zucca: C apr2–mag2 Melone: S mar2–apr1, T mag1–mag2 Anguria: S apr1, T mag1–mag2 Leguminose Fagiolo: C apr2–lug1 Fagiolino: C apr2–lug2 Pisello: C feb1–mar2, C ott2–nov1 Fava: C feb1–mar1, C ott2–nov2 Crucifere Cavolo cappuccio: S mar1–apr1, T apr2–mag2, S giu1–giu2, T lug1–lug2 Cavolfiore: S mag2–giu2, T lug1–lug2 Broccolo: S mag2–giu2, T lug1–ago1 Cavolo nero: S giu1–lug1, T lug2–ago2 Rucola: C mar1–mag1, C ago2–set2 Ravanello: C feb2–mag2, C ago2–ott1 Rapa: C ago1–set1 Liliacee Aglio: T feb1–mar1, T ott2–dic1 Cipolla: S gen2–feb2, T mar2–apr2, C ago2–set1 Porro: S feb2–apr1, T mag2–lug1 Scalogno: T feb1–mar2 Ombrellifere Carota: C feb2–lug1 Prezzemolo: C feb2–giu1 Sedano: S feb2–mar2, T mag1–giu1 Finocchio: C giu2–lug2 Composite Lattuga: S gen2–feb2, T mar1–apr1, C apr2–ago2 Radicchio: C giu2–lug2 Indivia: S giu1–lug1, T lug2–ago2 Carciofo: T mar2–apr2 Chenopodiacee Spinacio: C feb2–apr1, C ago2–ott1 Bietola: C mar2–giu2 Barbabietola: C mar2–giu1 Labiate Basilico: S mar1–apr1, T mag1–giu1 Rosmarino: T mar2–apr2 Salvia: T mar2–apr2`; // one line per crop, grouped by family const companions = String.raw` Pomodoro Basilico Carota Cipolla Aglio Lattuga Fagiolo Zucchina Cavolo PatataMarkdownの見本 · 10行 · 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' });キット · core, fonts, viewer, images:全レシピ共通 · 270行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#表を時計回りに回す
表頭はページの右端を向き、読者は本を反対向きに回します。
-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)よくあるつまずき
つまずき
'here'の表は分割されない
段やページをまたいで分割されるのはフロートの表だけで、'here'に置いた表はまとめて移動します。長い表はフロートにするか、インラインの表を短くしてください。 ページをまたぐ表 →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
章扉の画像は確保する高さに数えられない
postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。 デザインした章扉 →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
デザインのテキストのlineHeightは倍率で、寸法ではない
デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
- 表のセルの画像は、常にセルのテキストの上に描かれ、テキストと同じそろえ方になります。数字の横にアイコンを置くには、カレンダーが月にしているように、アイコンに専用の列を与えます。
- 分割した表の下の注は最後の部分にしか出ません。どの部分にも要る凡例はキャプションに入れます。
クレジット
- 本文
- 画像
- The opener’s field of seedlings in March, a watercolour · Generated With Diffusion Models · オリジナル
- The moon’s quarters and the ten crops, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- フォント
- Piazzolla (SIL OFL 1.1) · Gilda Display (SIL OFL 1.1) · Commissioner (SIL OFL 1.1)


