かんたんな説明
家庭菜園の会が出す2ページの会報です。メインの記事は広い段に入って2ページ目へ続き、短いお知らせはその横の狭い段に並びます。
できあがり
架空の市民農園の会が出す会報『The Allotment Gazette』の春号です。A4判1枚の両面に刷ります。麦わら色の帯には、Titan Oneで2行に組んだ題字、下端に一列の苗、号数と季節を入れたトマト色のタブを置きます。その下でページは113 mmの段と57 mmの段に分かれます。新しい3つの堆肥置き場を伝えるトップ記事は、コードで描いた敷地の配置図の下で広い段を埋め、「Continued on page 2」のタグで止まります。狭い段には5本の短信が入り、それぞれピル型のタグで始まります。2ページ目では送りの見出しの下で記事が再開し、温度のグラフを段の中央に置きます。さらに下では2段の囲みが、苗床の絵とその説明の段落を横に並べます。狭い段は8件のお知らせで埋まります。
このレシピが答える質問
- 幅の広い本文の段と幅の狭いサイド段からなる1段半のレイアウトにするには?
- Postextは3段以上の段組みや、画像の周りへの回り込みに対応していますか?
- 囲みの中を2段にするには(本文の段と図の段を並べる)?
- キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには?
手短な答え
const layout = {
layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
// defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
sideColumnPercent: 32, // 57 mm of the 178 mm measure
gutterWidth: mm(8), // the wide column keeps the other 113 mm
columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
// ## Shared compost bays open by the top gate ← the lead, in the wide column
// … :chip[Continued on page 2]{style="jump"}
// :::columnbreak ← the briefs open the narrow column
// ## In brief {style="rail"}
// …
// :::columnbreak ← from the last column: next page
// :chip[Continued from page 1]{style="jump"} ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).
材料
- 機能
- 1段半組み改ページと改段インラインのチップ囲みの中の段組み見出しスタイル図の配置囲みその場に置く図段間罫文書のメタデータデザインした章扉見出しの属性柱とノンブルページの役割ごとの柱段落スタイル独自のリソースの種類出典とクレジットセマンティックカラーパレットキャンバス上のページリソースとしての図と表
- 種類
- Work Sans, Titan One, Courier Prime (SIL OFL 1.1)
- 素材
- The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
作り方
#1 · Markdownで各段を埋める
レイアウトの設定は前の手短な答えにあります。oneAndHalfレイアウトでは狭い段も初期設定で本文を受け、本文は広い段、狭い段、次のページの広い段の順に流れます(レイアウトの種類)。:::columnbreakはその位置で段を終えます(ディレクティブ)。トップ記事の「Continued on page 2」タグのあとでは短信のために狭い段を開き、最後の短信のあと、つまりページの最後の段では2ページ目を開きます。この号では1ページ目の両方の段が、上端から275 mmの同じグリッド線で終わるように調整してあるため、どちらの区切りを消してもページは変わりません。区切りが効いてくるのは原稿が変わったときです。トップ記事の第2段落を削ると、最初の区切りがない場合、「In brief」は広い段の上から247 mm、タグの下で始まります。最初の短信は狭い段へはみ出し、その後半は書き始めの113 mmの行長を保ったまま、段の57 mmで切り取られます。
#2 · フロントマターから題字を組む
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
slot: { elements: [
// The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
// area on this page, behind the masthead, however deep the masthead is.
{ kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
{ kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
{ kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
// {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
// (gotcha: quote-frontmatter): an unquoted date prints nothing here.
{ kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
align: 'right', box: { backgroundColor: col('tomato'),
padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
placement: at('page', 'top-right', -SIDE, 44) },
] } };
H1には題字を描くデザインを持つ見出しスタイルを当て、span: 'page'でそのデザインを両方の段の上に置きます。60 ptの{titleText}は幅178 mmのボックスの中で2行に折り返します。タブは、H1の行に書いた{attr.issue}と、フロントマターの{publishDate}をつなげたものです。フロントマターの日付は引用符で囲んだ文字列にする必要があります(テキスト要素)。minHeightがないと、題字は上端から62 mmの帯の下端までしか場所を取らず、本文は次のグリッド線、62.5 mmから始まって帯に接してしまいます。グリッド線10本ぶんのminHeightを指定すると、本文は67 mmから始まります。
#3 · チップで短信にタグを付ける
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
chip('free', { background: col('marigold'), color: col('ink') }),
chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
color: col('leaf') }),
chip('jump', { background: col('straw'), color: col('ink') }),
];
chipStylesを宣言すると組み込みの淡い青のチップが置き換わり、styleのないチップには最初のスタイルが当たります(チップスタイル)。角の半径をボックスの高さの半分より大きくするとピル型になります。チップの中では改行しないので、行が切れるのはチップの前後のスペースだけになり、「Sat 11 April」のような日付は1行に収まります。チップスタイルには大文字・小文字を切り替える設定がないため、タグはMarkdownで大文字で入力してあります。上下のパディングは行の外側に描かれるので、13.4 ptのグリッドは崩れません。
#4 · 囲みの中で絵の横に文章を置く
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
// :::callout{type="bed" title="Sixteen squares by the gate"}
// :::columns{count=2 breaks="2"}
// The demonstration bed by the gate is 1.2 metres square … ← block 1
// ::resource{id="bed"} ← block 2
// :::
// ::: (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };
Postextは絵の周りに文字を回り込ませません。図はその段の帯をまるごと占めます。ただし囲みの中では、:::columnsグループでブロックを横に並べられます(:::columns)。breaks="2"で幅50 mmの2つ目の段を絵から始めるので、段落は1つ目の段に収まったままです。この号では段落(65.6 mm)が絵とキャプション(71.7 mm)より短く、breaks="2"を消しても何も変わりません。これは長い段落に備えた指定です。指定がないとグループは段末をそろえるため、20行の段落なら最後の2行が2つ目の段の頭、絵の上に移ります。
#5 · 細いグラフを帯の中央に置く
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
width: 0.62で、113 mmの段の中のグラフの幅は70.1 mmになり、align: 'center'で左右に21.5 mmずつ紙面が空きます(配置)。上下の本文は行長いっぱいのままで、その余白に流れ込むことはありません。絵はcaptionPrefixが空のリソースタイプに属するので、キャプションは前に「Figure 1」を付けず、それぞれの太字の語から始まります。
レシピの全体
// ═══ Postext Cookbook · Nº 042 · Community newsletter: lead story and briefs ═════ // https://postext.dev/en/cookbook/community-newsletter // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Work Sans, Titan One, Courier Prime (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'community-newsletter'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: garden colours, every one linked by id // ink: text · leaf: nameplate and headlines · tomato, the accent: tags, tab, column heads · // straw: the band · marigold: second tag · tint: the box · rule: hairlines · muted: notes const palette = { ink: '#1d211c', leaf: '#2f6b3a', tomato: '#c43f2a', straw: '#f2d492', marigold: '#e2b33c', tint: '#eef4e8', rule: '#cfd3c6', muted: '#5f6659', paper: '#fbfaf5' }; // The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ // defaults link to 'main-color': point it at the leaf, never blue ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY, LABEL] = ['Work Sans', 'Titan One', 'Courier Prime']; const SIDE = 16; // mm: the side margins of one A4 sheet, printed on both sides const LEAD = 13.4; // body leading in pt: the baseline grid const caps = (size, colour) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(size * 0.12), color: col(colour) }); const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region answer: a wide column for the lead, a narrow one for the briefs, one flow const layout = { layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it) sideColumnPercent: 32, // 57 mm of the 178 mm measure gutterWidth: mm(8), // the wide column keeps the other 113 mm columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter }; // The text runs down the wide column, then the narrow one, then the next page's wide // column. A :::columnbreak sends the next block on to the following column, so the // Markdown decides what each column holds: // ## Shared compost bays open by the top gate ← the lead, in the wide column // … :chip[Continued on page 2]{style="jump"} // :::columnbreak ← the briefs open the narrow column // ## In brief {style="rail"} // … // :::columnbreak ← from the last column: next page // :chip[Continued from page 1]{style="jump"} ← the lead goes on in the wide column // Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that // runs over into the other column keeps the measure it started with (gotcha: // split-paragraph-measure). // #endregion // #region masthead: the H1 is the nameplate; the issue tab reads the frontmatter const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85) const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text const masthead = { enabled: true, minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band slot: { elements: [ // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text // area on this page, behind the masthead, however deep the masthead is. { kind: 'box', id: 'band', style: { backgroundColor: col('straw') }, placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } }, { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } }, { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left', placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60), lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple) color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } }, // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter // (gotcha: quote-frontmatter): an unquoted date prints nothing here. { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'), align: 'right', box: { backgroundColor: col('tomato'), padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } }, placement: at('page', 'top-right', -SIDE, 44) }, ] } }; // #endregion // #region tags: four chip styles replace the built-in pale-blue chip const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true, fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size const chipStyles = [ chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default chip('free', { background: col('marigold'), color: col('ink') }), chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'), color: col('leaf') }), chip('jump', { background: col('straw'), color: col('ink') }), ]; // #endregion // #region box: two columns inside a box set a paragraph beside its drawing // Text never runs round a picture in a column (text wrap is a gap), but a :::columns group // inside a box sets blocks side by side; breaks="2" opens column two at the second block: // :::callout{type="bed" title="Sixteen squares by the gate"} // :::columns{count=2 breaks="2"} // The demonstration bed by the gate is 1.2 metres square … ← block 1 // ::resource{id="bed"} ← block 2 // ::: // ::: (gotcha: callout-columns) const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5), titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) }, body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } }; // #endregion // #region band: a narrower figure still takes the whole band of its column const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg', svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2], createdAt: 0, updatedAt: 0 }); // width is a fraction of the column and align where the figure sits in it: the text above // and below never moves into the white either side. const HEAT = { width: 0.62, align: 'center' }; const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT), drawing('bed', 50, 50), DRILL]; // Newsletter drawings carry no "Figure 1": an empty prefix prints no label. const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing', captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }]; // #endregion const head = (id, content, edge, x, y, look) => ({ kind: 'text', id, content, pages: 'body', align: edge.slice(4), placement: at('page', edge, x, y), ...look }); const folio = { elements: [ // page 2's head: title and date over a hairline, the folio right head('name', '{title} · {publishDate}', 'top-left', SIDE, 11, caps(7.5, 'leaf')), head('folio', '{pageNumber}', 'top-right', -SIDE, 9.6, { fontFamily: DISPLAY, fontSize: pt(12), color: col('tomato') }), { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), pages: 'body', placement: { ...at('page', 'top-left', SIDE, 15.5), size: { width: mm(210 - 2 * SIDE) } } }, ] }; const label = (size, look = {}) => ({ fontFamily: LABEL, fontSize: pt(size), ...look }); const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, resourceTypes, chipStyles, layout, calloutStyles: [bedBox], page: { width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(20), bottom: mm(18), left: mm(SIDE), right: mm(SIDE) } }, bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), referenceColor: col('leaf'), // for a :ref label: the palette reaches bold but not this // colour, which would stay the default blue (gotcha: palette-skips-designs) // Ragged: no hyphens, no runt check (gotchas: ragged-no-hyphenation, ragged-runts) textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('leaf'), levels: [ // Restated (gotcha: headings-drop-h1-break); span: 'page' sets the masthead over both columns { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontSize: pt(24), lineHeight: pt(2 * LEAD), marginTop: pt(0), marginBottom: pt(LEAD / 2) }, { level: 3, fontFamily: TEXT, fontWeight: 700, fontSize: pt(10.2), lineHeight: pt(LEAD), color: col('ink'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a brief's name { level: 4, ...caps(8.5, 'tomato'), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, // a notice's name ] }, headingStyles: [ // marginBottom 0: the level's 0.5 em is added under minHeight and would drop the text a line { id: 'masthead', marginBottom: pt(0), advancedDesign: masthead }, { id: 'rail', fontSize: pt(17), lineHeight: pt(2 * LEAD), color: col('tomato') }, { id: 'jump', fontSize: pt(19), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD / 2) }, ], paragraphStyles: [ { id: 'standfirst', fontSize: pt(12), lineHeight: pt(16) }, { id: 'byline', ...label(8, { color: col('muted'), marginBottom: pt(LEAD / 2) }) }, { id: 'jump', ...label(8, { textAlign: 'right' }) }, // 'Continued on page 2' { id: 'from', ...label(8) }, // 'Continued from page 1' { id: 'colophon', ...label(7, { lineHeight: pt(9.4), color: col('muted'), marginTop: pt(LEAD) }) }, ], captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), gap: mm(1.6), note: { fontFamily: LABEL, fontSize: pt(6.8), color: col('muted') } }, header: folio, footer: { elements: [] }, // the folio is at the head of page 2; page 1 has the masthead }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 116行 · content.en.md
title: "The Allotment Gazette" subtitle: "Newsletter of the Wren Lane Allotment Society" publishDate: "Spring 2026" --- # The Allotment Gazette {style="masthead" issue="No. 47"} :chip[SITE NEWS]{style="tag"} ## Shared compost bays open by the top gate :::paragraphs{style="standfirst"} Sixty plots now feed three timber bays instead of a heap each, and bay one reached 63 °C in five days. Its compost goes back to the plots in September. ::: :::paragraphs{style="byline"} Maureen Adeyemi, site secretary ::: ::resource{id="plan"} On the first Saturday of March eleven of us put up three bays of reclaimed scaffold boards by the top gate. Each is a metre and a half square and a metre high, with loose boards at the front that lift out, so a heap can be forked into the next bay. Hallam’s yard on Station Road let us have the boards for the cost of the van, and Ron Ellis brought his drill. Until now each plot has kept its own heap, and most of those heaps never get warm. A heap needs about a cubic metre of mixed material before it holds its heat, and few of us fill that much in a season. In one bay, with the stable manure the riding school leaves at the gate, the same waste heats up within days. :::paragraphs{style="jump"} :chip[Continued on page 2]{style="jump"} ::: :::columnbreak ## In brief {style="rail"} ### Path day :chip[WORK PARTY]{style="tag"} :chip[Sun 12 April]{style="when"} The grass paths between the plots need edging, and the main path a fresh load of gravel. Andy Price brings his edging iron; there are spades and barrows. Meet at the hut at nine. ### Seed swap :chip[EVENT]{style="tag"} :chip[Sat 11 April]{style="when"} Bring spare seed in labelled envelopes to the hut between ten and twelve. Seed saved from open-pollinated plants is welcome; seed from F1 hybrids will not come true. Tea and flapjack, 50p. ### Stable manure :chip[FREE]{style="free"} Whitecross Riding School leaves a trailer-load at the top gate every other Tuesday. Take what your plot needs, but top up bay one first, and keep the gateway clear for the tractor. ### Jam jars and a gazebo :chip[WANTED]{style="tag"} Clean jars with lids for the jam stall at the summer show, and a gazebo to borrow on Saturday 18 July. Leave jars in the crate by the hut door; Sue Marsh collects them on Fridays. ### New neighbours :chip[WELCOME]{style="free"} Four plots changed hands this winter. Welcome to the Okafors on plot 9, Priya and Tom on 23a, Mr Hensley on 31, and the Year 5 class at St Anne’s, who share plot 40 with Mrs Dunn. :::columnbreak :::paragraphs{style="from"} :chip[Continued from page 1]{style="jump"} ::: ## What goes into the bays {style="jump"} Bay one was filled on 7 March. On the 12th the thermometer at its centre read 63 °C, and the heap stayed at 55 °C or more for six days, long enough to kill most weed seeds. The morning readings are chalked on the board by the gate. Fill one bay at a time, with green and brown in equal barrow-loads: grass cuttings, weeds without seed heads, spent crops and peelings with torn cardboard, straw and manure. Chop anything thicker than a thumb. Keep out clubroot-infected brassica roots, bindweed and couch grass, and cooked food, which brings rats. ::resource{id="heat"} Turning lets air into the middle of the heap, and the temperature climbs again: bay one, turned on 18 March, was back above 60 °C two days later. The rota on the hut door puts two plots in charge of the fork each week. In September every plot can take away two barrow-loads of finished compost, and whatever is left goes on the demonstration bed by the gate. :::callout{type="bed" title="Sixteen squares by the gate"} :::columns{count=2 breaks="2"} The demonstration bed by the gate is 1.2 metres square, edged with the same scaffold boards as the bays. This year it holds compost from the council depot, 15 centimetres deep; next spring it gets our own. Strings divide the bed into sixteen squares, 30 centimetres a side, each planted with one, four, nine or sixteen plants according to the size of the crop. Year 5 from St Anne’s planted it on 20 March and will keep its diary. ::resource{id="bed"} ::: ::: :::columnbreak ## Notices {style="rail"} #### Rents Rents for 2026 are £52 for a full plot and £28 for a half, due by the end of April. Pay Dev Sharma on plot 18, or by bank transfer: the details are on the hut door. #### Water The troughs are filled from 1 April to the end of October. Use a hose to fill cans only, and never leave one running on a bed. #### Bonfires None from April to September, and none at any time when the wind blows towards the houses on Wren Lane. #### Sheds A new shed over two metres tall needs the committee’s approval. Send a sketch with its size to the secretary before you buy. #### Waiting list Thirty-one names are on the list. We expect to offer eight plots this year, most of them half plots. #### Bees On sunny Saturdays in April, Jan inspects the two hives on plot 52. Keep to the main path while her flag is up. #### Lost A green ten-litre watering can with a brass rose. Please bring it back to plot 7. #### Next issue Please leave copy for the summer issue in the letterbox on the hut door by 15 June. :::paragraphs{style="colophon"} The Allotment Gazette is written and put together by members of the Wren Lane Allotment Society. Set in Work Sans, Titan One and Courier Prime (SIL Open Font License) · Text: original, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the site plan, the heap's temperature and the square-metre bed function mulberry32(seed) { // a seeded PRNG: the same plots on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k const f = (v) => +v.toFixed(2); const P = palette; const SOIL = mix(P.tomato, P.ink, 0.62); const EARTH = mix(SOIL, P.straw, 0.28); const GRASS = mix(P.tint, P.leaf, 0.3); const DEEP = mix(P.leaf, P.ink, 0.35); const svgOf = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`; const dot = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`; const rect = (x, y, w, h, fill, extra = '') => `<rect x="${f(x)}" y="${f(y)}" width="${f(w)}" ` + `height="${f(h)}" fill="${fill}"${extra}/>`; const line = (x1, y1, x2, y2, stroke, width) => `<path d="M${f(x1)} ${f(y1)}L${f(x2)} ${f(y2)}" ` + `stroke="${stroke}" stroke-width="${width}" stroke-linecap="round"/>`; // One plot seen from above: rows of crops across it, sometimes a shed or a greenhouse. function plot(x, y, w, h, rand, extra) { let out = rect(x, y, w, h, EARTH); const strips = 2 + Math.floor(rand() * 3); let top = y + 0.8; for (let s = 0; s < strips; s++) { const room = y + h - 0.8 - top; if (room < 2) break; let sh = s === strips - 1 ? room : Math.max(4, (h - 1.6) * (0.2 + rand() * 0.3)); if (room - sh < 4) sh = room; // no strip thinner than 4 mm const kind = Math.floor(rand() * 6); const [x0, x1] = [x + 0.9, x + w - 0.9]; if (kind === 0) { // rows of lettuce and onions for (let yy = top + 1; yy < top + sh - 0.6; yy += 1.7) { for (let xx = x0 + 0.6; xx < x1; xx += 1.5) out += dot(xx, yy, 0.55, P.leaf); } } else if (kind === 1) { // peas and beans up canes for (let yy = top + 1.2; yy < top + sh - 0.8; yy += 2.6) out += line(x0, yy, x1, yy, DEEP, 1); } else if (kind === 2) { // brassicas under netting out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, mix(P.tint, P.paper, 0.4)); for (let yy = top + 1.6; yy < top + sh - 1.2; yy += 3) { for (let xx = x0 + 1.4; xx < x1 - 0.8; xx += 3) { out += dot(xx, yy, 1.1, mix(P.leaf, P.rule, 0.45)); } } } else if (kind === 3) { // rhubarb and squashes for (let xx = x0 + 2; xx < x1 - 1; xx += 4 + rand() * 2) { const yy = top + sh / 2 + (rand() - 0.5) * (sh - 3); out += dot(xx, yy, 1.8, P.leaf) + dot(xx + 0.6, yy - 0.5, 0.45, P.marigold); } } else if (kind === 4) { // dug over and raked for (let yy = top + 0.7; yy < top + sh - 0.3; yy += 0.9) { out += line(x0, yy, x1, yy, SOIL, 0.25); } } else { // straw round the strawberries out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, P.straw); for (let xx = x0 + 1; xx < x1; xx += 2.2) { out += dot(xx, top + sh / 2 - 0.2, 0.8, P.leaf) + dot(xx + 0.4, top + sh / 2 + 0.3, 0.3, P.tomato); } } top += sh; } if (extra === 'shed') { // a shed with its felt roof in tomato, and a water butt const sx = rand() < 0.5 ? x + 0.6 : x + w - 5.1; out += rect(sx, y + 0.6, 4.5, 3.4, P.tomato) + line(sx, y + 2.3, sx + 4.5, y + 2.3, SOIL, 0.3) + dot(sx + (sx > x + 1 ? -1.3 : 5.8), y + 1.6, 0.9, P.ink); } else if (extra === 'glass') { // a greenhouse: glass over the whole end of the plot out += rect(x + 0.8, y + h - 7.2, w - 1.6, 6.4, mix(P.paper, P.tint, 0.6)) + [0.25, 0.5, 0.75].map((k) => line(x + 0.8 + k * (w - 1.6), y + h - 7.2, x + 0.8 + k * (w - 1.6), y + h - 0.8, P.rule, 0.3)).join(''); } return out; } function planSvg(face) { // 114 × 56.5 mm: the top of the Wren Lane site, north up const rand = mulberry32(1921); // the year the society took the lease const [W, H, PATH] = [114, 56.5, 26.5]; const label = (lx, ly, s, fill, anchor = 'start') => `<text x="${lx}" y="${ly}" ` + `font-size="2.3" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(0, 0, W, H, GRASS) + rect(0, PATH, W, 4.5, P.straw); // the main path, gravel out += label(W - 2, PATH + 3, t({ en: 'MAIN PATH', es: 'CAMINO' }), SOIL, 'end'); // The top gate at the end of the path, and the corner behind it. out += rect(0, PATH - 0.8, 1.2, 1.2, P.ink) + rect(0, PATH + 4.1, 1.2, 1.2, P.ink) + label(2.2, PATH + 3, t({ en: 'TOP GATE', es: 'PUERTA DE ARRIBA' }), SOIL); out += rect(2, 2.4, 11, 8, P.tomato) + rect(2, 6.4, 11, 4, mix(P.tomato, P.ink, 0.22)) // the hut + label(7.5, 7.2, t({ en: 'HUT', es: 'CASETA' }), P.paper, 'middle'); out += rect(15.5, 3.2, 11.5, 3.6, mix(P.rule, P.ink, 0.3)) // the trough + rect(16.1, 3.8, 10.3, 2.4, mix(P.tint, P.rule, 0.4)); for (let i = 0; i < 3; i++) { // the three bays in fresh boards: full, half full, empty const bx = 2 + i * 8.6; out += rect(bx, 13.5, 7.8, 7.8, P.marigold) + rect(bx + 0.8, 14.3, 6.2, 6.2, SOIL); for (let k = 0; k < 16 - i * 7; k++) { out += dot(bx + 1.5 + rand() * 4.8, 15 + rand() * 4.8, 0.5, i === 0 ? DEEP : EARTH); } out += label(bx + 3.9, 24.6, i + 1, P.ink, 'middle'); } out += dot(25.5, 10.5, 3.6, DEEP); // the old pear // Plots: four above the path, five below; one split in halves, grass paths between. const row = (x0, x1, y, h, n, extras) => { const w = (x1 - x0 - (n - 1) * 1.4) / n; for (let i = 0; i < n; i++) { const px = x0 + i * (w + 1.4); if (extras[i] === 'halves') { const half = (h - 1.4) / 2; out += plot(px, y, w, half, rand) + plot(px, y + half + 1.4, w, half, rand, 'shed'); } else out += plot(px, y, w, h, rand, extras[i]); } }; row(31, W - 2, 2, PATH - 3.5, 4, ['shed', 'halves', '', 'shed']); row(2, W - 2, PATH + 6.5, H - PATH - 8.5, 5, ['', 'shed', 'glass', '', 'shed']); return svgOf(W, H, out, face); } // An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so the // chart carries its label face inline, as a data URL of the Fontsource file. async function inlineFace(family, weight) { const id = family.toLowerCase().replace(/\s+/g, '-'); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}` + '-normal.woff2'; const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return `<style>@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) ` + `format('woff2')}text{font-family:L;font-weight:700}</style>`; } const HEAT_LOG = [12, 24, 38, 49, 57, 63, 64, 62, 59, 55, 51, 47, 56, 61, 62, 60, 57, 54, 51, 48, 46, 44]; // °C at the heap's centre, 7 to 28 March; turned after the reading on the 18th function heatSvg(face) { // 68 × 40 mm; the plot runs 7–28 March and 0–70 °C const [W, H, X0, X1, Y0, Y1] = [68, 40, 9, 66, 3, 33]; const x = (day) => X0 + ((day - 7) / 21) * (X1 - X0); const y = (deg) => Y1 - (deg / 70) * (Y1 - Y0); const text = (tx, ty, s, fill, anchor = 'end', size = 2.5) => `<text x="${f(tx)}" y="${f(ty)}" ` + `font-size="${size}" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(X0, y(65), X1 - X0, y(55) - y(65), P.straw); // where weed seeds die for (const deg of [20, 40, 60]) { out += line(X0, y(deg), X1, y(deg), P.rule, 0.2) + text(X0 - 1.2, y(deg) + 0.9, deg, P.muted); } out += text(X0 - 1.2, y(70) + 0.9, '°C', P.muted); // the unit, over the scale for (const day of [7, 14, 21, 28]) { out += line(x(day), Y1, x(day), Y1 + 1, P.ink, 0.25) + text(x(day), Y1 + 3.8, day === 28 ? t({ en: '28 March', es: '28 marzo' }) : day, P.muted, day === 28 ? 'end' : 'middle'); } out += line(x(18.5), y(70), x(18.5), Y1, P.leaf, 0.35) + text(x(18.5) + 1, y(70) + 2.2, t({ en: 'turned', es: 'volteo' }), P.leaf, 'start'); const pts = HEAT_LOG.map((deg, i) => [x(7 + i), y(deg)]); out += `<path d="M${pts.map(([px, py]) => `${f(px)} ${f(py)}`).join('L')}" fill="none" ` + `stroke="${P.tomato}" stroke-width="0.55" stroke-linejoin="round"/>`; out += pts.map(([px, py]) => dot(px, py, 0.6, P.tomato)).join(''); out += line(X0, Y1, X1, Y1, P.ink, 0.3); return svgOf(W, H, out, face); } // The square-metre bed: sixteen squares of 30 cm, one to sixteen plants in each. const CROPS = { // plants per square, size and colour of each plant seen from above cabbage: [1, 4.2, mix(P.leaf, P.rule, 0.35)], lettuce: [4, 2.1, mix(P.leaf, P.straw, 0.35)], chard: [4, 1.9, mix(P.leaf, P.tomato, 0.3)], marigold: [4, 1.5, P.marigold], beetroot: [9, 1.1, mix(P.tomato, P.ink, 0.35)], beans: [9, 1.2, P.leaf], onion: [9, 0.8, mix(P.straw, P.paper, 0.3)], radish: [16, 0.6, P.tomato], carrot: [16, 0.55, mix(P.marigold, P.tomato, 0.35)], }; const BED = [['cabbage', 'lettuce', 'lettuce', 'marigold'], ['beetroot', 'radish', 'carrot', 'onion'], ['chard', 'beans', 'beetroot', 'radish'], ['marigold', 'onion', 'carrot', 'lettuce']]; function bedSvg() { // 50 × 50 mm: 1.2 m of bed inside its scaffold boards const [S, BOARD] = [50, 2.6]; const cell = (S - 2 * BOARD) / 4; let out = rect(0, 0, S, S, P.marigold) + rect(BOARD, BOARD, S - 2 * BOARD, S - 2 * BOARD, SOIL); BED.forEach((cropRow, r) => cropRow.forEach((crop, c) => { const [n, size, fill] = CROPS[crop]; const k = Math.sqrt(n); for (let i = 0; i < n; i++) { const cx = BOARD + c * cell + ((i % k) + 0.5) * (cell / k); const cy = BOARD + r * cell + (Math.floor(i / k) + 0.5) * (cell / k); out += dot(cx, cy, size, fill); if (crop === 'marigold') out += dot(cx, cy, size * 0.4, P.tomato); } })); for (let i = 1; i < 4; i++) { // the strings const at = BOARD + i * cell; out += line(at, BOARD, at, S - BOARD, P.paper, 0.25) + line(BOARD, at, S - BOARD, at, P.paper, 0.25); } return svgOf(S, S, out); } // A drill of seedlings along the foot of the masthead band, 210 × 8 mm: some show only their // seed leaves, the rest their first true leaves too. function leaf(x, y, len, wid, deg, fill) { // a leaf from its stalk end, deg from the vertical const a = (deg * Math.PI) / 180; const [dx, dy] = [Math.sin(a), -Math.cos(a)]; const [mx, my, px, py] = [x + (dx * len) / 2, y + (dy * len) / 2, -dy * wid, dx * wid]; return `<path d="M${f(x)} ${f(y)}Q${f(mx + px)} ${f(my + py)} ${f(x + dx * len)} ` + `${f(y + dy * len)}Q${f(mx - px)} ${f(my - py)} ${f(x)} ${f(y)}Z" fill="${fill}"/>`; } function drillSvg() { const rand = mulberry32(47); // the issue number const [W, H, RIDGE] = [210, 8, 1.3]; const SEED = mix(P.leaf, P.straw, 0.3); let out = rect(0, H - RIDGE, W, RIDGE, SOIL); for (let x = 2.6; x < W - 1; x += 4.6 + rand() * 1.2) { const ground = H - RIDGE; const grown = rand() < 0.62; const stem = grown ? 2.6 + rand() * 1.2 : 1.2 + rand() * 0.8; const lean = (rand() - 0.5) * 10; out += line(x, ground, x, ground - stem, P.leaf, 0.45); const top = ground - stem; if (grown) { out += leaf(x, ground - stem * 0.45, 1.6, 0.55, -68 + lean, SEED) + leaf(x, ground - stem * 0.45, 1.6, 0.55, 68 + lean, SEED) + leaf(x, top, 2.6 + rand() * 0.5, 1, -32 + lean, P.leaf) + leaf(x, top, 2.6 + rand() * 0.5, 1, 32 + lean, P.leaf); } else { out += leaf(x, top, 1.7, 0.6, -58 + lean, SEED) + leaf(x, top, 1.7, 0.6, 58 + lean, SEED); } } return svgOf(W, H, out); } // What the drawings say under them, in the sample's language: caption, credit note, alt text. const CAPTIONS = { plan: [t({ en: '**The top of the Wren Lane site, from the air.** The three new bays stand between ' + 'the hut and the top gate.', es: '**La parte alta del Soto, desde el aire.** Los tres cajones nuevos están entre la ' + 'caseta y la puerta de arriba.' }), t({ en: 'Drawing: The Gazette, from the society’s site plan', es: 'Dibujo: La Gaceta, a partir del plano de la asociación' }), t({ en: 'Plan of allotment plots seen from above, with three compost bays by the gate.', es: 'Plano de parcelas de huerto vistas desde arriba, con tres cajones de compost junto a la ' + 'puerta.' })], heat: [t({ en: '**Bay one in March.** Temperature at the centre of the heap, read at nine each ' + 'morning. In the shaded band, 55 to 65 °C, most weed seeds die.', es: '**El cajón uno en marzo.** Temperatura en el centro, a las nueve. En la franja, de ' + '55 a 65 °C, mueren casi todas las semillas de malas hierbas.' }), t({ en: 'Readings: the compost rota', es: 'Lecturas: el turno del compost' }), t({ en: 'Line chart: the heap rises from 12 °C to 64 °C in six days, cools to 47 °C, and ' + 'climbs back to 62 °C after it is turned on 18 March.', es: 'Gráfico de líneas: el montón sube de 12 °C a 64 °C en seis días, baja a 47 °C y vuelve ' + 'a 62 °C después del volteo del 18 de marzo.' })], bed: [t({ en: '**What goes where.** One cabbage to a square; four lettuces, chard or marigolds; ' + 'nine beetroot, beans or onions; sixteen radishes or carrots.', es: '**Cómo se planta el bancal.** Una col por cuadro; cuatro lechugas, acelgas o ' + 'caléndulas; nueve remolachas, judías o cebollas; dieciséis rábanos o zanahorias.' }), t({ en: 'Drawing: The Gazette', es: 'Dibujo: La Gaceta' }), t({ en: 'A square bed divided by strings into sixteen squares, each planted with one to ' + 'sixteen plants.', es: 'Un bancal cuadrado dividido con cuerdas en dieciséis cuadros, ' + 'cada uno con entre una y dieciséis plantas.' })], }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Text, display and label faces, loaded before the build (gotcha: fonts-first). const FONTS = { 'Work Sans': ['400', '700'], 'Titan One': ['400'], 'Courier Prime': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const face = await inlineFace(LABEL, 700); // the label face, for the drawings' own lettering await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)), loadSvg('bed.svg', bedSvg()), loadSvg('drill.svg', drillSvg())]); const doc = await buildWithFonts( () => buildDocument({ markdown, resources: resources() }, config()), markdown); showPages(doc, { title: t({ en: 'Community newsletter', es: 'Boletín vecinal' }) });キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
よくあるつまずき
つまずき
段落は、始まった段の行長を保つ
postext 1.4.1では、段落は始まった段の幅で一度だけ行に分けられます。1段半のページで両方の段に本文がある場合、広い段から狭い段へ続く段落は長い行のままで、狭い段の端で切れます。狭い段から次のページの広い段へ続く段落は短い行のままです。各段は段落の切れ目で終わらせてください。原稿を収め、最後の段落のあとに:::columnbreakを置けば、次のブロックは次の段から始まります。 1段半組み →
つまずき
:::columnsは囲みの中でしか効かず、分割されない
:::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。 囲みの中の段組み →
つまずき
フロントマターの値はすべて引用符で囲む
YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください(title: "1984")。 文書のメタデータ →
つまずき
行末不ぞろいのテキストはハイフネーションされない
ハイフネーションが効くのは両端そろえのテキストだけです。左そろえのテキストは語と語の間で改行するため、幅の狭い左そろえの段では行末の凹凸が大きくなります。その箇所を両端そろえにするか、行長を広げてください。 ハイフネーションと文書の言語 →
つまずき
行末不ぞろいのテキストはラントを検査されない
optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。 ウィドウ、オーファン、ラント →
つまずき
ノーブレークスペースでも改行される
postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める(0.08%)か、文を書き換えてください。 エスケープとそのままの文字 →
つまずき
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を含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
- このレイアウトでは、1ページ目の段間罫は、題字がグリッド線を何本確保していても版面の上端、つまり題字の裏から始まります。ここでは麦わら色の帯がその上に描かれて隠れますが、塗りつぶした帯のない題字では、罫が題字を貫いて見えます。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- 画像
- The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- フォント
- Work Sans (SIL OFL 1.1) · Titan One (SIL OFL 1.1) · Courier Prime (SIL OFL 1.1)


