かんたんな説明
街路樹と夏の暑さを扱った、科学の学会用の大きなポスターです。とても大きな1ページの上に、文章やグラフの枠を段に分けて並べる方法を示します。
できあがり
街路樹と歩道の暑さについての架空の研究を、600 × 800 mmの1ページの学会ポスターに組みます。緑の帯には学会名、110 ptのBitterの題、所属記号付きの著者名、地理学部(School of Geography)のマークが入ります。2段組みの要旨の下に、枠のない囲みを1つ置いて本文の2段をまたがせ、その中に6つのパネルを3段のグリッドに並べます。パネルはそれぞれが独立した囲みで、6 ptの帯の下に置かれます。中央の段は150 ptのオレンジ色の−4.2 °Cで始まり、街区と時刻ごとの気温を示すヒートマップが色見本の凡例とともに続き、舗装の棒グラフが来ます。下の余白には、キーワードのチップを並べた色付きの帯を置きます。同じファイルでスペイン語版も組め、CodePenのサンプルではページを1,701 × 2,268 pxのPNGとして書き出せます。
このレシピが答える質問
- ページの途中に、2段をまたぐ囲み(3つ並びの「数字で見る」欄など)を置くには?
- 解答欄を含むワークシートのカードのように、囲みを正確な間隔で入れ子にするには?
- 囲みの中を2段にするには(本文の段と図の段を並べる)?
- 本文、キャプション、表の注に色見本(凡例の色)を加えるには?
手短な答え
// The body text runs in two columns. Three columns exist only inside a box: a :::columns
// group in a callout, and span="page" lays that callout across both body columns.
// :::callout{type="grid" span="page"} ← one frameless box across the page
// :::columns{count=3 breaks="3,4"} ← panel 3 opens column 2, panel 4 column 3
// :::callout{type="panel" title="Introduction"} ← each panel is a box nested in it
// …
// ::: ← closes the panel; then the other panels
// ::: ← closes the columns group
// ::: ← closes the grid (gotcha: callout-columns)
const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
columnGap: mm(GAP) };
// A nested box takes the width of its column and ignores span and placement (gotcha:
// nested-callout-limits). Its one device is a 6 pt stripe along the top.
const panel = { id: 'panel', backgroundEnabled: false,
stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') },
padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) },
titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700,
textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) },
body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText
marginTop: mm(18) };
const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat
stripe: { ...panel.stripe, color: col('heat') },
titleStyle: { ...panel.titleStyle, color: col('heat') } };
材料
- 機能
- 囲みの中の段組み入れ子の囲み段抜きの囲みデザインした章扉見出しの属性デザイン要素の基準位置ページデザインの画像色見本インラインのチップセマンティックカラーパレット囲みその場に置く図段落スタイル仕上がりサイズキャンバス上のページ番号付きキャプション出典とクレジットフロートする囲みベースライングリッド段抜きの章見出し帯
- 種類
- Rethink Sans, Bitter, Saira Condensed (SIL OFL 1.1)
- 素材
- The plan of the street, the heat map, the bar chart and the School of Geography's mark, drawn in code from synthetic data in the poster's palette (Ignacio Ferro, CC BY 4.0)
作り方
#1 · 72 dpiでレイアウトする
const SHEET = { width: 600, height: 800, margin: 25 }; // mm: a portrait board, 25 mm all round
const GAP = 12; // mm between the two body columns and between the three panel columns
// The summary's leading is the page's baseline grid: 56 lines fill the 750 mm between the
// margins, so a box floated to the foot of the page ends on the bottom margin.
const LEAD = (((SHEET.height - 2 * SHEET.margin) / 25.4) * 72) / 56; // 37.96 pt
const page = { width: mm(SHEET.width), height: mm(SHEET.height),
// At 72 dpi a point is a pixel: the page is 1,701 × 2,268 px. The default 300 dpi would give
// 7,087 × 9,449 px (268 MB of canvas) for the same line breaks.
dpi: 72, backgroundColor: col('paper'),
margins: { top: mm(SHEET.margin), bottom: mm(SHEET.margin), left: mm(SHEET.margin),
right: mm(SHEET.margin) } }; // one sheet, nothing to mirror
既定の300 dpiでも行の分割は72 dpiのときと同じですが、ページは7,087 × 9,449 pxになり、描画すると268 MBのピクセルになります(ページサイズのプリセット)。72 dpiでは1ポイントが1ピクセルです。ビューアーはrenderPageToCanvasとscaleでサムネイルを描画し、renderPageは学会のオンラインギャラリーが求める1,701 × 2,268 pxのPNGを出力します(ページをビットマップに描画する)。行送りは天地の余白のあいだの750 mmを56行に割るので、ページの地へフロートとして配置したキーワードの帯は、ちょうど下の余白の線で終わります。きりのよい38 ptにすると55行しか入らず、最終行は余白まで12.7 mm足りません。するとキーワードの帯はパネルの下に収まらなくなり、2ページ目に移ります。
#2 · 題の帯を見出しから描く
const BAND = 158; // mm from the top edge to the foot of the green field
const type = (id, content, family, size, look, placement) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left',
overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
lineHeight: 1.2, // a multiple of the size (gotcha: design-lineheight-multiple)
...look, placement });
const band = { enabled: true,
// The field ends 133 mm under the top margin; eleven grid lines (147 mm) start the summary
// 14 mm below it.
minHeight: pt(11 * LEAD),
slot: { elements: [
{ kind: 'box', id: 'field', style: { backgroundColor: col('canopy') },
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
type('meeting', '{attr.meeting}', 'Saira Condensed', 22, { fontWeight: 600,
textTransform: 'uppercase', letterSpacing: pt(3.5), color: col('tint') },
{ ...at('page', 'top-left', SHEET.margin, 18), size: { width: mm(420) } }),
type('title', '{titleText}', 'Bitter', 110, { fontWeight: 800, lineHeight: 1 },
{ ...at('#meeting', 'below', 0, 7), size: { width: mm(380) } }),
type('authors', '{attr.authors}', 'Rethink Sans', 30, { fontWeight: 700 },
{ ...at('#title', 'below', 0, 7), size: { width: 'fill' } }),
// Design text prints plain text, so the affiliation marks are the characters ¹ ² ³
// (gotcha: design-text-no-inline-marks).
type('affiliations', '{attr.affiliations}', 'Rethink Sans', 21, { color: col('tint') },
{ ...at('#authors', 'below', 0, 2), size: { width: 'fill' } }),
type('number', '{attr.poster}', 'Saira Condensed', 30, { fontWeight: 700,
color: col('canopy'), box: { backgroundColor: col('paper'),
padding: { top: mm(1.5), right: mm(4), bottom: mm(1), left: mm(4) } } },
at('page', 'top-right', -SHEET.margin, 15)),
// The School of Geography's mark, drawn in code.
{ kind: 'image', id: 'mark', resourceId: 'mark',
placement: { ...at('page', 'top-right', -SHEET.margin, 38), size: { width: mm(92) } } },
] } };
H1は学会名、ポスター番号、著者名、所属を属性として持ち、そのデザインがこれらを、ページに固定した深さ158 mmの緑のボックスの上に組みます(幅と詳細デザイン)。ボックスは天の余白から133 mm下で終わります。minHeightがないと要旨は緑の0.9 mm下から始まりますが、グリッド線11本分のminHeightを指定すると14 mm空きます。デザインのテキストはプレーンテキストとして印刷されるので、所属記号には文字¹ ² ³を使います。Latin-1の範囲にある上付き数字はこの3つだけです。
#3 · ページ幅の囲み1つにパネルを入れ子にする
// The body text runs in two columns. Three columns exist only inside a box: a :::columns
// group in a callout, and span="page" lays that callout across both body columns.
// :::callout{type="grid" span="page"} ← one frameless box across the page
// :::columns{count=3 breaks="3,4"} ← panel 3 opens column 2, panel 4 column 3
// :::callout{type="panel" title="Introduction"} ← each panel is a box nested in it
// …
// ::: ← closes the panel; then the other panels
// ::: ← closes the columns group
// ::: ← closes the grid (gotcha: callout-columns)
const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
columnGap: mm(GAP) };
// A nested box takes the width of its column and ignores span and placement (gotcha:
// nested-callout-limits). Its one device is a 6 pt stripe along the top.
const panel = { id: 'panel', backgroundEnabled: false,
stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') },
padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) },
titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700,
textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) },
body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText
marginTop: mm(18) };
const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat
stripe: { ...panel.stripe, color: col('heat') },
titleStyle: { ...panel.titleStyle, color: col('heat') } };
本文の段組みは2段までなので、グリッドは囲みの中の:::columns{count=3}グループにし、span="page"でその囲みを要旨の下で本文の両方の段にまたがらせます(:::columns)。各パネルはグループの中に入れ子にした囲みです。それぞれが独自のスタイルを持ち、段の幅175 mmと、上のパネルからのmarginTop 18 mmを取ります(:::calloutコンテナー)。breaks="3,4"は3つ目のパネルResultsを中央の段の頭へ、4つ目のConclusionsを最後の段の頭へ送ります。これがないとグループは段の高さをそろえようとし、積み重ねの3分の1ごとの位置に最も近いパネルで切ります。この原稿では切れ目が同じ2つのパネルに来ますが、原稿が変わってもこの属性が切れ目をそこに保ちます。
#4 · 図をパネルの中にとどめる
// "Figure 1", not "Figure 1.1": the poster has no chapters.
const figureType = { ...defaultResourceTypes(LANG)[0], numberingTemplate: '{n}' };
const resourceTypes = [figureType];
const figure = (id, [width, height], caption, altText) => ({ id, typeId: figureType.id,
kind: 'svg', svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 },
placement: { position: 'here' }, // stays in its panel, where ::resource puts it; else floats
caption, altText, note: t({ en: 'Synthetic data, generated for this poster.',
es: 'Datos sintéticos, generados para este póster.' }), createdAt: 0, updatedAt: 0 });
'here'に配置した図は、::resourceを書いたパネルの中にとどまります。既定の配置では3つの図がパネルを離れ、ポスターは2ページ目にはみ出します。numberingTemplate: '{n}'で図番号は1、2、3になります。既定の{h1}.{n}では、題がH1なのでFigure 1.1と印刷されます(リソースの種類)。各キャプションの下の注で、データが合成データであることを示します。
#5 · ヒートマップの色を凡例と共有する
const palette = {
ink: '#102a2c', // text: a green-black
paper: '#f7faf7', // the sheet, and type on the band
canopy: '#2e7d4f', // the band, the stripes and titles of five panels, the tree crowns
tint: '#e6f2ea', // the keyword strip, the park on the plan, lines of type on the band
rule: '#bcd2c4', // buildings on the plan, the chart's grid
muted: '#587068', // the notes under the figures, the axis titles inside them
// The key of Figure 2, the air against the street mean at the same hour. The heat map's
// cells and the :swatch runs of its key read the same four entries.
cool: '#3b82c4', // −1.5 °C or less
mist: '#a9c9e6', // −1.5 to 0 °C
blush: '#f2b492', // 0 to +1.5 °C
heat: '#d9572b', // +1.5 °C or more; also −4.2 °C, the Results panel, the loggers, the sun
};
// 1.4.1 designs paint the hex and ignore the paletteId (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 canopy green
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
{ id: 'main-color', name: 'canopy (defaults)', value: { hex: palette.canopy, model: 'hex' } },
];
ヒートマップのセルと凡例の4つの:swatch{color="…"}は同じパレットの項目を指しているので、coolの値を変えると地図と凡例が一緒に塗り直されます(インライン書式)。凡例はResultsパネルの中に置いた2つ目の:::columnsグループで、1段に2項目ずつ入ります。どの色にも16進値を書いておきます。1.4.1が帯のデザインで描画するのはその16進値です。
#6 · 大きな数字を段落スタイルで組む
const paragraphStyles = [
// Paragraph styles apply inside boxes, nested ones included. They have no weight in
// 1.4.1, so the figure is written **bold**: that sets it in Bitter 700 and in boldColor.
{ id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130),
boldColor: col('heat') },
{ id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8) },
{ id: 'key', fontSize: pt(19), lineHeight: pt(28) }, // the key of Figure 2
];
const chipStyles = [{ id: 'keyword', background: col('paper'), borderColor: col('canopy'),
borderWidth: pt(1.5), borderRadius: em(1), paddingX: em(0.55), paddingY: em(0.14),
color: col('canopy'), bold: true, gap: em(0.35) }];
const strip = { id: 'strip', background: col('tint'), // one device: a tint
padding: { top: mm(4), right: mm(8), bottom: mm(4), left: mm(8) }, columnGap: mm(GAP),
body: { fontSize: pt(18), lineHeight: pt(24) } };
段落スタイルは囲みの中でも、入れ子の囲みの中でも効くので、−4.2 °CはResultsパネルの中に置いた:::paragraphs{style="stat"}のブロックにし、150 ptのBitterで組みます(段落スタイル)。1.4.1では段落スタイルでウェイトを指定できないので、数字は太字で書き、Bitter 700で印刷されるようにします。もう1つのスタイルで、文献一覧に9 mmのぶら下げインデントを付けます。キーワードの帯はplacement="bottom"の色付きの囲みで、グリッドの下、ページの地へフロートとして配置します。
レシピの全体
// ═══ Postext Cookbook · Nº 059 · Research poster on one big page ══════════════════ // https://postext.dev/en/cookbook/research-poster // Code: MIT · Text and data: original, synthetic (CC BY 4.0) · Figures: generated in code // Fonts: Rethink Sans, Bitter, Saira Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPage, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'research-poster'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: six colours for the poster, four for the heat map's key const palette = { ink: '#102a2c', // text: a green-black paper: '#f7faf7', // the sheet, and type on the band canopy: '#2e7d4f', // the band, the stripes and titles of five panels, the tree crowns tint: '#e6f2ea', // the keyword strip, the park on the plan, lines of type on the band rule: '#bcd2c4', // buildings on the plan, the chart's grid muted: '#587068', // the notes under the figures, the axis titles inside them // The key of Figure 2, the air against the street mean at the same hour. The heat map's // cells and the :swatch runs of its key read the same four entries. cool: '#3b82c4', // −1.5 °C or less mist: '#a9c9e6', // −1.5 to 0 °C blush: '#f2b492', // 0 to +1.5 °C heat: '#d9572b', // +1.5 °C or more; also −4.2 °C, the Results panel, the loggers, the sun }; // 1.4.1 designs paint the hex and ignore the paletteId (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 canopy green ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'canopy (defaults)', value: { hex: palette.canopy, model: 'hex' } }, ]; // #endregion // #region sheet: one 600 × 800 mm page, laid out at 72 dpi const SHEET = { width: 600, height: 800, margin: 25 }; // mm: a portrait board, 25 mm all round const GAP = 12; // mm between the two body columns and between the three panel columns // The summary's leading is the page's baseline grid: 56 lines fill the 750 mm between the // margins, so a box floated to the foot of the page ends on the bottom margin. const LEAD = (((SHEET.height - 2 * SHEET.margin) / 25.4) * 72) / 56; // 37.96 pt const page = { width: mm(SHEET.width), height: mm(SHEET.height), // At 72 dpi a point is a pixel: the page is 1,701 × 2,268 px. The default 300 dpi would give // 7,087 × 9,449 px (268 MB of canvas) for the same line breaks. dpi: 72, backgroundColor: col('paper'), margins: { top: mm(SHEET.margin), bottom: mm(SHEET.margin), left: mm(SHEET.margin), right: mm(SHEET.margin) } }; // one sheet, nothing to mirror // #endregion const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region band: the title band is the H1's design, filled from its attributes const BAND = 158; // mm from the top edge to the foot of the green field const type = (id, content, family, size, look, placement) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left', overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default) lineHeight: 1.2, // a multiple of the size (gotcha: design-lineheight-multiple) ...look, placement }); const band = { enabled: true, // The field ends 133 mm under the top margin; eleven grid lines (147 mm) start the summary // 14 mm below it. minHeight: pt(11 * LEAD), slot: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('canopy') }, placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } }, type('meeting', '{attr.meeting}', 'Saira Condensed', 22, { fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(3.5), color: col('tint') }, { ...at('page', 'top-left', SHEET.margin, 18), size: { width: mm(420) } }), type('title', '{titleText}', 'Bitter', 110, { fontWeight: 800, lineHeight: 1 }, { ...at('#meeting', 'below', 0, 7), size: { width: mm(380) } }), type('authors', '{attr.authors}', 'Rethink Sans', 30, { fontWeight: 700 }, { ...at('#title', 'below', 0, 7), size: { width: 'fill' } }), // Design text prints plain text, so the affiliation marks are the characters ¹ ² ³ // (gotcha: design-text-no-inline-marks). type('affiliations', '{attr.affiliations}', 'Rethink Sans', 21, { color: col('tint') }, { ...at('#authors', 'below', 0, 2), size: { width: 'fill' } }), type('number', '{attr.poster}', 'Saira Condensed', 30, { fontWeight: 700, color: col('canopy'), box: { backgroundColor: col('paper'), padding: { top: mm(1.5), right: mm(4), bottom: mm(1), left: mm(4) } } }, at('page', 'top-right', -SHEET.margin, 15)), // The School of Geography's mark, drawn in code. { kind: 'image', id: 'mark', resourceId: 'mark', placement: { ...at('page', 'top-right', -SHEET.margin, 38), size: { width: mm(92) } } }, ] } }; // #endregion // #region answer: three columns of panels inside one box across the page // The body text runs in two columns. Three columns exist only inside a box: a :::columns // group in a callout, and span="page" lays that callout across both body columns. // :::callout{type="grid" span="page"} ← one frameless box across the page // :::columns{count=3 breaks="3,4"} ← panel 3 opens column 2, panel 4 column 3 // :::callout{type="panel" title="Introduction"} ← each panel is a box nested in it // … // ::: ← closes the panel; then the other panels // ::: ← closes the columns group // ::: ← closes the grid (gotcha: callout-columns) const grid = { id: 'grid', backgroundEnabled: false, // no fill or frame; each panel has a stripe padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, columnGap: mm(GAP) }; // A nested box takes the width of its column and ignores span and placement (gotcha: // nested-callout-limits). Its one device is a 6 pt stripe along the top. const panel = { id: 'panel', backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(6), color: col('canopy') }, padding: { top: mm(5), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { fontFamily: 'Saira Condensed', fontSize: pt(40), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(4), color: col('canopy'), gap: mm(4) }, body: { fontSize: pt(22), lineHeight: pt(30) }, // the rest follows bodyText marginTop: mm(18) }; const results = { ...panel, id: 'results', // the finding: the same panel, striped in heat stripe: { ...panel.stripe, color: col('heat') }, titleStyle: { ...panel.titleStyle, color: col('heat') } }; // #endregion // #region type: paragraph styles for the figure, the references and the key; the foot strip const paragraphStyles = [ // Paragraph styles apply inside boxes, nested ones included. They have no weight in // 1.4.1, so the figure is written **bold**: that sets it in Bitter 700 and in boldColor. { id: 'stat', fontFamily: 'Bitter', fontSize: pt(150), lineHeight: pt(130), boldColor: col('heat') }, { id: 'refs', fontSize: pt(19), lineHeight: pt(26), hangingIndent: mm(9), spaceBetween: pt(8) }, { id: 'key', fontSize: pt(19), lineHeight: pt(28) }, // the key of Figure 2 ]; const chipStyles = [{ id: 'keyword', background: col('paper'), borderColor: col('canopy'), borderWidth: pt(1.5), borderRadius: em(1), paddingX: em(0.55), paddingY: em(0.14), color: col('canopy'), bold: true, gap: em(0.35) }]; const strip = { id: 'strip', background: col('tint'), // one device: a tint padding: { top: mm(4), right: mm(8), bottom: mm(4), left: mm(8) }, columnGap: mm(GAP), body: { fontSize: pt(18), lineHeight: pt(24) } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, page, layout: { layoutType: 'double', gutterWidth: mm(GAP) }, // The summary's type; the boxes take the family, the rag and the paragraph spacing from it. bodyText: { fontFamily: 'Rethink Sans', fontSize: pt(28), lineHeight: pt(LEAD), color: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // Bold in the boxes and the :ref labels copy boldColor, and the italics of the references' // paragraph style take italicColor (gotcha: style-italic-colour); both are green otherwise. boldColor: col('ink'), italicColor: col('ink') }, // The band draws the title, but 1.4.1 still measures the H1's own text: in Bitter, a face // already loaded, instead of the default Open Sans 700. headings: { fontFamily: 'Bitter', levels: [ // Restated (gotcha: headings-drop-h1-break): a second poster in the file starts a page. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), advancedDesign: band }] }, calloutStyles: [grid, panel, results, strip], paragraphStyles, chipStyles, resourceTypes, captionStyle: { fontSize: pt(19), labelColor: col('canopy'), gap: mm(3), note: { fontSize: pt(17), color: col('muted') } }, header: { elements: [] }, footer: { elements: [] }, // a poster has no running heads }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // #region figures: three drawings set inside their panels, numbered 1, 2, 3 // "Figure 1", not "Figure 1.1": the poster has no chapters. const figureType = { ...defaultResourceTypes(LANG)[0], numberingTemplate: '{n}' }; const resourceTypes = [figureType]; const figure = (id, [width, height], caption, altText) => ({ id, typeId: figureType.id, kind: 'svg', svg: { fileId: `${id}.svg`, width: width * 10, height: height * 10 }, placement: { position: 'here' }, // stays in its panel, where ::resource puts it; else floats caption, altText, note: t({ en: 'Synthetic data, generated for this poster.', es: 'Datos sintéticos, generados para este póster.' }), createdAt: 0, updatedAt: 0 }); // #endregion const markdown = String.raw`---Markdownの見本 · 95行 · content.en.md
title: "Street trees and sidewalk heat" author: "Amara Osei, Tomás Lindqvist, Hannah Kiel" --- # Street trees and sidewalk heat {meeting="Fourth Meeting on Streets and Climate · Wrenfield, 14–16 October 2026" poster="P-47" authors="Amara Osei¹ · Tomás Lindqvist² · Hannah Kiel¹,³" affiliations="¹ School of Geography, University of Wrenfield ² Street Trees Team, Wrenfield City Council ³ Lowmoor Institute for Urban Climate"} **Summary.** We logged the air at head height on the sixteen blocks of Ashby Road, and photographed both sidewalks with a thermal camera, on the fourteen afternoons of 2025 above 30 °C. From 13:00 to 17:00 the air on the blocks under 70 % canopy or more was 4.2 °C cooler than on the near-treeless blocks of the Parade, and the paving 15 °C cooler. The council will use the result to choose which of its 212 empty tree pits to plant first. :::callout{type="grid" span="page"} :::columns{count=3 breaks="3,4"} :::callout{type="panel" title="Introduction"} By mid-afternoon in July a sunlit concrete sidewalk can pass 50 °C, and the air above it warms with it. Tree crowns stop the sun before it reaches the paving [1, 2]. Most studies compare a park with the streets around it [3]. Ashby Road runs from bare paving to crowns that meet over the road, so each block can be compared with its neighbours on the same afternoon. **Question.** How much cooler is a block for each step of canopy cover, and at which hours? ::: :::callout{type="panel" title="Methods"} Ashby Road (:ref{id="plan"}) runs 800 m from Ashby Park to the bus station, in sixteen blocks of 50 m. Planes from 1908 line the park end and limes from the 1950s shade blocks 13 to 15; the Parade, a row of shops, has almost no trees. ::resource{id="plan"} - **Air.** A logger per block, 1.5 m up a lamp post, read every 5 minutes. - **Paving.** A thermal camera walked both sidewalks at 15:00. - **Canopy.** The share of sidewalk under crowns, traced on aerial photographs. Air is given against the street mean at the same hour, which takes out the day's weather. ::: :::callout{type="results" title="Results"} :::paragraphs{style="stat"} **−4.2 °C** ::: **Air at head height from 13:00 to 17:00, under 70 % canopy or more, against the blocks under 10 %.** The planes and the limes keep their blocks cool all afternoon; the Parade stays warm (:ref{id="heat"}). :::columns{count=2 breaks="3"} :::paragraphs{style="key"} :swatch{color="cool"} −1.5 °C or less :swatch{color="mist"} −1.5 to 0 °C :swatch{color="blush"} 0 to +1.5 °C :swatch{color="heat"} +1.5 °C or more ::: ::: :::space{lines=0.33} ::resource{id="heat"} At 15:00 the paving (:ref{id="bars"}) was 48.4 °C on the Parade and 33.4 °C under the densest crowns. ::resource{id="bars"} ::: :::callout{type="panel" title="Conclusions"} Every 10 % of canopy cover took 2.1 °C off the paving at 15:00, and 0.6 °C off the air between 13:00 and 17:00. The effect is largest in the afternoon, when people walk home. Before 09:00 and after 19:00 the difference stays under 1.5 °C, and at 20:00 the shade is 0.4 °C warmer. The Parade is the hottest 200 m of the street. If the relation holds, the paving there would drop from 48.4 °C to about 39 °C at 15:00 once new crowns cover half the sidewalk. ::: :::callout{type="panel" title="Planting plan"} The council plants the hottest blocks first. We will repeat the survey in the summer of 2028. 1. **Blocks 7–10**, the Parade: 38 trees in 2026. 2. **Block 16**, the bus station: 9 trees in 2027. 3. **Blocks 11–12**: 21 trees in 2027. 4. **Blocks 5–6**: 12 trees in 2028. ::: :::callout{type="panel" title="References"} :::paragraphs{style="refs"} [1] Oke, T.R. (1982). The energetic basis of the urban heat island. *Quarterly Journal of the Royal Meteorological Society*, 108, 1–24. [2] Armson, D., Stringer, P. and Ennos, A.R. (2012). The effect of tree shade and grass on surface and globe temperatures in an urban area. *Urban Forestry & Urban Greening*, 11, 245–255. [3] Bowler, D.E., Buyung-Ali, L., Knight, T.M. and Pullin, A.S. (2010). Urban greening to cool towns and cities: a systematic review of the empirical evidence. *Landscape and Urban Planning*, 97, 147–155. ::: :::space{lines=0.75} We thank the Wrenfield street-lighting depot for the lamp posts, and Inês Barros for walking the thermal survey fourteen times. ::: ::: ::: :::callout{type="strip" span="page" placement="bottom"} :::columns{count=2 breaks="2"} **Keywords** :chip[street trees]{style="keyword"} :chip[urban heat]{style="keyword"} :chip[canopy cover]{style="keyword"} :chip[thermal imaging]{style="keyword"} The street, the council, the authors and the data are invented for the Postext Cookbook. Set in Rethink Sans, Bitter and Saira Condensed (SIL OFL). Text and figures CC BY 4.0. ::: :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the synthetic survey, the plan, the heat map, the bars and the mark function mulberry32(seed) { // a seeded PRNG: the same survey 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; }; } // Sixteen 50 m blocks, west to east: the park's planes, houses, the Parade, new planting, // old limes and the bus station. Canopy is the share of sidewalk under crowns, in %. const CANOPY = [84, 78, 72, 66, 45, 31, 4, 0, 6, 9, 18, 27, 58, 74, 71, 12]; const HOURS = [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; // How much of the shade shows in the air at each hour: most at 14:00–15:00, reversed by 20:00. const SUN = [0.3, 0.4, 0.55, 0.7, 0.85, 0.95, 1, 1, 0.95, 0.8, 0.55, 0.25, -0.1]; const SURVEY = (() => { // air: °C against the street mean at the hour; surface: °C at 15:00 const rand = mulberry32(25); // Six uniforms summed and scaled: close enough to a normal draw with a mean of 0 and an SD of 1. const noise = () => [...Array(6)].reduce((sum) => sum + rand(), -3) / Math.sqrt(0.5); const mean = CANOPY.reduce((a, b) => a + b) / CANOPY.length; return CANOPY.map((c) => ({ canopy: c, air: SUN.map((w) => (w * 6.2 * (mean - c)) / 100 + 0.22 * noise()), surface: 49.2 - 0.205 * c + 0.7 * noise() })); })(); const CLASSES = [[0, 10], [10, 30], [30, 50], [50, 70], [70, 101]]; // canopy classes, % const avg = (values) => values.reduce((a, b) => a + b, 0) / values.length; const surfaceOf = ([lo, hi]) => avg(SURVEY.filter((b) => b.canopy >= lo && b.canopy < hi) .map((b) => b.surface)); const classOf = (v) => (v <= -1.5 ? 'cool' : v < 0 ? 'mist' : v < 1.5 ? 'blush' : 'heat'); const f = (v) => +v.toFixed(2); const X0 = 12; // mm: the left gutter of the plan and the heat map const CELL = (175 - X0) / CANOPY.length; // mm per block: the plan and Figure 2 share columns const cx = (i) => f(X0 + (i + 0.5) * CELL); const label = (x, y, text, size, fill = palette.ink, anchor = 'middle', extra = '') => `<text x="${f(x)}" y="${f(y)}" font-size="${size}" text-anchor="${anchor}" fill="${fill}"` + `${extra}>${text}</text>`; const svg = (w, h, body, face) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${face}${body}</svg>`; // An SVG drawn as an image cannot use the page's web fonts (gotcha: svg-no-webfonts), so // each drawing 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:F;src:url(data:font/woff2;base64,${btoa(bin)}) ` + `format('woff2')}text{font-family:F}</style>`; } 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 FIG = { plan: [175, 62], heat: [175, 126], bars: [175, 83] }; // mm, at column width const LABEL = 5.4; // mm: figure labels, about 15 pt function planSvg(face) { // Figure 1: a schematic plan, west on the left const rand = mulberry32(1908); // seeded with the year the planes were planted const [W, H] = FIG.plan; const [NB, NS, ROAD, SS, SB] = [[11, 22], [22, 25], [25, 34], [34, 37], [37, 48]]; // y bands const SIDE = [4, 6, 10, 13]; // side streets east of these blocks (block index from 0) let out = `<rect x="${X0}" y="${ROAD[0]}" width="${W - X0}" height="${ROAD[1] - ROAD[0]}" ` + `fill="${mix(palette.paper, palette.ink, 0.16)}"/>`; for (let x = X0 + 2; x < W - 2; x += 6) { // the centre line out += `<path d="M${f(x)} 29.5h3" stroke="${palette.paper}" stroke-width="0.4"/>`; } CANOPY.forEach((c, i) => { // the frontages: houses, the park, the shops const x = X0 + i * CELL; const w = SIDE.includes(i) ? CELL - 3.2 : CELL; if (i < 3) { out += `<rect x="${f(x)}" y="${NB[0] - 2}" width="${f(w)}" height="${NB[1] - NB[0] + 2}" ` + `fill="${palette.tint}"/>`; } else { const fill = i >= 6 && i <= 9 ? mix(palette.rule, palette.ink, 0.3) : palette.rule; for (const [y0, y1] of [NB, SB]) { const cut = rand() * 0.3 + 0.35; out += `<rect x="${f(x + 0.4)}" y="${y0}" width="${f(w * cut - 0.8)}" height="${y1 - y0}"` + ` fill="${fill}"/><rect x="${f(x + w * cut + 0.4)}" y="${y0}" ` + `width="${f(w * (1 - cut) - 0.8)}" height="${y1 - y0}" fill="${fill}"/>`; } } }); out += `<rect x="${f(X0)}" y="${SB[0]}" width="${f(3 * CELL - 0.8)}" height="${SB[1] - SB[0]}" ` + `fill="${palette.rule}"/>`; // houses face the park across the road CANOPY.forEach((c, i) => { // crowns along both sidewalks, as many as the canopy share const n = Math.round((c / 100) * 3.6); // Under 14 % the share rounds to no crown: such a block gets one tree on the south side. const rows = n > 0 ? [[NS, n], [SS, n]] : c > 0 ? [[SS, 1]] : []; for (const [[y0, y1], count] of rows) { for (let k = 0; k < count; k++) { const x = X0 + i * CELL + ((k + 0.5) / count) * CELL + (rand() - 0.5) * 1.2; const r = 2 + (c / 100) * 1.3 + rand() * 0.5; // the old planes have the widest crowns out += `<circle cx="${f(x)}" cy="${f((y0 + y1) / 2)}" r="${f(r)}" fill="${palette.canopy}"` + ` fill-opacity="0.85" stroke="${palette.paper}" stroke-width="0.3"/>`; } } if (i < 3) { // the park's own trees for (let k = 0; k < 4; k++) { out += `<circle cx="${f(X0 + i * CELL + 2 + rand() * (CELL - 4))}" ` + `cy="${f(NB[0] + 1 + rand() * 7)}" r="${f(1.8 + rand())}" fill="${palette.canopy}" ` + 'fill-opacity="0.55"/>'; } } }); CANOPY.forEach((c, i) => { // the loggers, on the north kerb, one per block out += `<circle cx="${cx(i)}" cy="${NS[1]}" r="1.4" fill="${palette.heat}" ` + `stroke="${palette.ink}" stroke-width="0.35"/>` + label(cx(i), 55, i + 1, LABEL); }); const names = t({ en: ['ASHBY PARK', 'THE PARADE', 'BUS STATION'], es: ['PARQUE DE LA ALAMEDA', 'TRAMO COMERCIAL', 'ESTACIÓN DE AUTOBUSES'] }); const track = ' letter-spacing="0.4"'; out += label(X0, 6.5, names[0], LABEL - 0.6, palette.canopy, 'start', track) + label(X0 + 8 * CELL, 6.5, names[1], LABEL - 0.6, palette.ink, 'middle', track) + label(W, 6.5, names[2], LABEL - 0.6, palette.ink, 'end', track); // The north arrow in the gutter: a line and a triangle. out += `<path d="M5 27V18" stroke="${palette.ink}" stroke-width="0.7"/>` + `<path d="M5 13l2.6 5.4h-5.2z" fill="${palette.ink}"/>` + label(5, 34, 'N', LABEL); const bar = 2 * CELL; // 100 m: two blocks out += `<path d="M${X0} 60.6h${f(bar)}" stroke="${palette.ink}" stroke-width="0.9"/>` + label(X0 + bar + 2, 61.8, '100 m', LABEL - 0.6, palette.ink, 'start'); return svg(W, H, out, face); } function heatSvg(face) { // Figure 2: canopy bars over an hour × block heat map const [W, H] = FIG.heat; const [BASE, TOP, ROW] = [27, 30, 6.2]; // mm: foot of the bars, top of the grid, row height let out = ''; SURVEY.forEach((b, i) => { const h = (b.canopy / 100) * 18; out += `<rect x="${f(X0 + i * CELL + 1.6)}" y="${f(BASE - h)}" width="${f(CELL - 3.2)}" ` + `height="${f(h)}" fill="${palette.canopy}"/>` + label(cx(i), f(BASE - h - 1.6), b.canopy, LABEL - 0.8); b.air.forEach((v, r) => { out += `<rect x="${f(X0 + i * CELL + 0.3)}" y="${f(TOP + r * ROW + 0.3)}" ` + `width="${f(CELL - 0.6)}" height="${f(ROW - 0.6)}" fill="${palette[classOf(v)]}"/>`; }); out += label(cx(i), TOP + HOURS.length * ROW + 6.5, i + 1, LABEL); }); out += `<path d="M${X0} ${BASE + 0.2}H${W}" stroke="${palette.ink}" stroke-width="0.4"/>` + label(X0 - 1.5, BASE - 7, '%', LABEL, palette.ink, 'end'); // level with the bars HOURS.forEach((hour, r) => { if (hour % 2 === 0) { out += label(X0 - 1.5, TOP + r * ROW + 5, String(hour).padStart(2, '0'), LABEL, palette.ink, 'end'); } }); const track = ' letter-spacing="0.4"'; const [west, east, hour] = t({ en: ['WEST', 'EAST', 'HOUR'], es: ['OESTE', 'ESTE', 'HORA'] }); const mid = f(TOP + (HOURS.length * ROW) / 2); // the hour axis's title runs up the gutter out += label(X0, H - 0.5, west, LABEL - 1, palette.muted, 'start', track) + label(W, H - 0.5, east, LABEL - 1, palette.muted, 'end', track) + label(0, 0, hour, LABEL - 1, palette.muted, 'middle', `${track} transform="translate(3.4 ${mid}) rotate(-90)"`); return svg(W, H, out, face); } function barsSvg(face) { // Figure 3: how much cooler the paving was, by canopy class const [W, H] = FIG.bars; const [LEFT, ROW, SCALE] = [40, 13, 7.6]; // SCALE: mm per °C const names = t({ en: ['under 10 %', '10–30 %', '30–50 %', '50–70 %', '70 % or more'], es: ['menos del 10 %', '10–30 %', '30–50 %', '50–70 %', '70 % o más'] }); const base = surfaceOf(CLASSES[0]); // the paving under 10 % canopy: the zero of the scale const num = (v) => t({ en: v.toFixed(1), es: v.toFixed(1).replace('.', ',') }); const foot = 5 * ROW + 2; let out = ''; for (const step of [5, 10, 15]) { out += `<path d="M${LEFT + step * SCALE} 1V${foot}" stroke="${palette.rule}" ` + 'stroke-width="0.35"/>' + label(LEFT + step * SCALE, foot + 6.5, step, LABEL); } CLASSES.forEach((cls, i) => { const cooler = base - surfaceOf(cls); const y = 1 + i * ROW; const fill = mix(palette.tint, palette.canopy, 0.3 + 0.7 * (i / 4)); out += label(LEFT - 3, y + 8.3, names[i], LABEL, palette.ink, 'end'); if (i === 0) out += label(LEFT + 2, y + 8.3, `${num(base)} °C`, LABEL, palette.muted, 'start'); else { out += `<rect x="${LEFT}" y="${y + 1.2}" width="${f(cooler * SCALE)}" height="${ROW - 2.4}" ` + `fill="${fill}"/>` + label(LEFT + cooler * SCALE + 2, y + 8.3, num(cooler), LABEL, palette.ink, 'start'); } }); const axis = t({ en: '°C cooler than the blocks under 10 %', es: '°C por debajo de las manzanas de menos del 10 %' }); out += `<path d="M${LEFT} 0V${foot + 1}" stroke="${palette.ink}" stroke-width="0.6"/>` + label(LEFT, foot + 6.5, '0', LABEL) + label(LEFT + 7.5 * SCALE, foot + 14, axis, LABEL, palette.muted, 'middle'); return svg(W, H, out, face); } function markSvg() { // the School of Geography's mark: a crown shading a street, in a ring const shade = mix(palette.canopy, palette.ink, 0.45); return svg(10, 10, '<g transform="scale(0.1)">' + `<circle cx="50" cy="50" r="46" fill="none" stroke="${palette.paper}" stroke-width="3.2"/>` + `<circle cx="70" cy="25" r="8" fill="${palette.heat}"/>` // the sun, behind the crown + `<ellipse cx="43" cy="71" rx="23" ry="4.5" fill="${shade}"/>` // its shade on the paving + `<rect x="47" y="48" width="6" height="23" fill="${palette.paper}"/>` + `<circle cx="50" cy="37" r="17" fill="${palette.paper}"/>` + `<circle cx="35" cy="46" r="11" fill="${palette.paper}"/>` + `<circle cx="65" cy="46" r="11" fill="${palette.paper}"/>` + `<path d="M17 71H83" stroke="${palette.paper}" stroke-width="3.2"/></g>`, ''); } // #endregion const resources = [ figure('plan', FIG.plan, t({ en: 'Ashby Road, schematic plan: crowns from the canopy survey and the 16 loggers ' + '(orange). Widths across the street are not to scale.', es: 'La avenida en plano esquemático: las copas del inventario de arbolado y los 16 ' + 'registradores (naranja). Los anchos transversales no están a escala.' }), t({ en: 'Plan of a straight street in 16 numbered blocks, with a park at the west end, tree ' + 'crowns along both sidewalks and one tree or none on each of blocks 7 to 10.', es: 'Plano de una calle recta en 16 manzanas numeradas, con un parque en el extremo oeste, ' + 'copas en las dos aceras y un árbol o ninguno en cada una de las manzanas 7 a 10.' })), figure('heat', FIG.heat, t({ en: 'Canopy cover per block (bars, %) and the air at head height against the street mean ' + 'at the same hour, from 08:00 to 20:00; mean of 14 afternoons.', es: 'Copa por manzana (barras, %) y aire a la altura de la cabeza respecto a la media de ' + 'la calle a esa hora, de 08:00 a 20:00; media de 14 tardes.' }), t({ en: 'Bar chart of canopy cover for 16 blocks above a grid of coloured cells, hours down ' + 'and blocks across: blue under the tree-lined blocks in the afternoon, orange along ' + 'blocks 7 to 10.', es: 'Barras de cobertura de copa de 16 manzanas sobre una cuadrícula de celdas de color, ' + 'horas hacia abajo y manzanas en horizontal: azul bajo las manzanas arboladas por la ' + 'tarde, naranja en las manzanas 7 a 10.' })), figure('bars', FIG.bars, t({ en: 'The paving at 15:00 by canopy class, as the difference from the blocks under 10 %.', es: 'El pavimento a las 15:00 por clase de copa, como diferencia con las manzanas de ' + 'menos del 10 %.' }), t({ en: 'Horizontal bars that grow with the canopy class, from 2.7 to 15.0 °C cooler.', es: 'Barras horizontales que crecen con la clase de copa, de 2,7 a 15,0 °C menos.' })), // Not cited in the text: only the band's design draws it. { id: 'mark', typeId: figureType.id, kind: 'svg', svg: { fileId: 'mark.svg', width: 100, height: 100 }, altText: t({ en: 'The mark of the School of Geography', es: 'La marca del Departamento de Geografía' }), createdAt: 0, updatedAt: 0 }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Rethink Sans': ['400', '400i', '700'], Bitter: ['400', '700', '800'], // 400: the stat style's base face, which the build measures 'Saira Condensed': ['600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const face = await inlineFace('Saira Condensed', 600); await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)), loadSvg('bars.svg', barsSvg(face)), loadSvg('mark.svg', markSvg())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Research poster', es: 'Póster científico' }) }); // The e-poster: renderPage paints the page at its own size, 1,701 × 2,268 px at 72 dpi. const png = Object.assign(document.createElement('a'), { download: `${RECIPE}.png`, textContent: t({ en: 'E-poster PNG', es: 'PNG del póster digital' }) }); renderPage(doc.pages[0], doc).toBlob((blob) => { png.href = URL.createObjectURL(blob); }); document.getElementById('pt-actions').append(png);キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#e-posterを150 dpiで書き出す
大きなファイルを求める学会には、改行位置が同じ3,543 × 4,724 pxのPNGを渡せます。
- dpi: 72, backgroundColor: col('paper'),
+ dpi: 150, backgroundColor: col('paper'),よくあるつまずき
つまずき
:::columnsは囲みの中でしか効かず、分割されない
:::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。 囲みの中の段組み →
つまずき
入れ子の囲みはspan、placement、snapToGridを無視する
ほかの囲みの中に入れ子にした囲みは、自分のspan、placement、snapToGrid、floatBarrierを無視し、常に親の内側の幅で親の中を流れます。 入れ子の囲み →
つまずき
デザインのテキストではインラインの^sup^や**bold**が使えない
デザインのテキスト要素はプレーンテキストを印字するため、属性内の^1^や**bold**はそのまま出ます。Unicodeの上付き数字(¹ ² ³はlatinサブセットに含まれます)を使うか、別のウェイトの要素をもう1つ置いてください。 ページデザインのテキスト・罫・ボックス →
つまずき
囲みの中の表や図には前後の空きがない
postext 1.4.1では、::resourceで位置'here'に置いた表や図は、:::calloutの中では本文中で取る空きをまったく取らず、上下の段落に接します。::resourceの行の前に:::spaceを置き、あとにテキストが続くときはもう1つ置いてください。lines=0.33のように1行の端数を指定すれば、小さな空きになります。 その場に置く図 →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
段落スタイルにはイタリックの色がない
postext 1.4.1の段落スタイルはcolorとboldColorを設定できますが、italicColorはなく、イタリックの部分はbodyText.italicColorを使います。抑えた色のスタイル(小さな注記、出典の行)では、イタリックの書名が周りの文字より濃く印字されます。こうしたスタイルは本文と同じ色にするか、その中でイタリックを使わないでください。 段落スタイル →
つまずき
行末不ぞろいのテキストはラントを検査されない
optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。 ウィドウ、オーファン、ラント →
つまずき
行末不ぞろいのテキストでは、太字や:refと隣の約物の間で改行されることがある
postext 1.4.1では、両端そろえでないテキスト(囲みの本文、左そろえの段落)は、太字やイタリックの部分、あるいは:refと、それに接する約物の間で改行されることがあります。ピリオドが次の行の頭に来たり、参照の前の「(」が前の行の末尾に残ったりします。両端そろえのテキストではそこで改行されません。すべての版の囲みを読み、そうなった文は、その部分が行の途中に来るように書き換えてください。 太字・イタリックとその色 →
つまずき
ノーブレークスペースでも改行される
postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める(0.08%)か、文を書き換えてください。 エスケープとそのままの文字 →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
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'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
- Rethink Sansは、11(3)の号数のように括弧で囲んだ1桁の数字を、Canvas上で丸数字として描きます。そのため文献一覧には巻とページだけを書いています。
- 1.4.1では、文書の最後に来るページ幅のボックスは、その下に本文2行分の空きが残らないと新しいページへ移ります。そこでキーワードの帯は
placement="bottom"でフロートとして配置し、グリッドの下に残った50 mmの中で、下の余白の線上に置きます。 - 1.4.1では、
:::paragraphsグループの後の段落は、そのスタイルのspaceBetweenだけしか離れず、ここでは最後の文献の2.8 mm下に来ます。謝辞の前に:::space{lines=0.75}を置くと10.8 mm空き、パネル内のほかの段落のあいだの10.6 mmに近くなります。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- 画像
- The plan of the street, the heat map, the bar chart and the School of Geography's mark, drawn in code from synthetic data in the poster's palette · Ignacio Ferro · CC BY 4.0
- フォント
- Rethink Sans (SIL OFL 1.1) · Bitter (SIL OFL 1.1) · Saira Condensed (SIL OFL 1.1)


