跳到主要内容
食谱编号59

排版食谱 · 第1章 · 页面与网格

一整张大页上的学术海报

600 × 800 mm的会议海报:标题色带下是一个通栏的框,里面三栏嵌套面板,热力图配色块图例,还有150 pt的−4.2 °C。

  • 英文样例:尚无中文版本
  • 成品尺寸600 × 800 mm
  • 2栏, 栏间距12 mm
  • Rethink Sans 28/38
  • Bitter
  • Saira Condensed
  • 1页
  • 难度
  • Postext 1.4.1
  • 排版用时8 ms
  • 204行代码

成品一览

一张会议海报,内容是一项虚构的研究:行道树与人行道温度。整张海报排在一个600 × 800 mm的页面上。绿色色带上是会议名称、110 pt Bitter标题、带单位标记的作者和地理学院的标志。两栏摘要下方,一个不带边框的框横跨两个正文栏,里面是六块面板组成的三栏网格,每块面板是自己的框,上方有一条6 pt的色条。中间一栏以150 pt的橙色−4.2 °C开头,接着是按街区和时段显示气温的热力图,配彩色色块图例,然后是一张路面材料柱状图。一条带底色的关键词标签长条位于下页边距上。同一文件还能排出西班牙语版本,CodePen示例还提供1,701 × 2,268 px的PNG页面。

这道食谱解答

  • 怎样在页面中间排一个横跨两栏的框,比如三格并列的“数字一览”面板?
  • 怎样嵌套框,比如装着答题框的练习卡片,并精确控制间距?
  • 怎样在框里分两栏(文字栏旁边是图栏)?
  • 怎样在正文、题注或表注里加入色块图例?

简短回答

script.js · 第88–111行在完整代码中
// 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排整张海报

script.js · 第38–48行在完整代码中
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下一个点就是一个像素。查看器用renderPageToCanvas加scale绘制缩略图,renderPage输出会议线上展厅要求的1,701 × 2,268 px PNG(把页面渲染成位图)。行距把上下页边距之间的750 mm分成56行,所以浮动到页脚的关键词长条正好结束在下页边距上。行距取整为38 pt时只能排55行,最后一行离页边距还差12.7 mm;关键词长条就放不进面板下方,移到了第二页。

#2 · 用标题画出标题色带

script.js · 第53–84行在完整代码中
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处开始;minHeight设为十一个网格行,留出14 mm。设计文字只印纯文本,所以单位标记用的是字符¹ ² ³,它们是Latin-1范围内仅有的上标数字。

#3 · 把面板嵌套在一个通栏框里

script.js · 第88–111行在完整代码中
// 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') } };

正文最多两栏,所以网格是标注框里的一个:::columns{count=3}分栏组,span="page"让这个标注框在摘要下方横跨两个正文栏(:::columns)。每块面板是嵌套在分栏组里的标注框:它有自己的样式,宽度取栏宽175 mm,与上方面板之间有18 mm的marginTop(:::callout容器)。breaks="3,4"把第三块面板Results送到中间一栏顶部,把第四块Conclusions送到最后一栏顶部。不设它时,分栏组会自行齐底,在最接近总高三分之一处的面板切开。按现在的文字,切点正好落在这两块面板上;文字改动后,这个属性让切点保持不变。

#4 · 让每幅图留在自己的面板里

script.js · 第155–162行在完整代码中
// "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 });

placement为'here'的图留在::resource所在的面板里;用默认placement时,三幅图会离开各自的面板,海报排到第二页。numberingTemplate: '{n}'把它们编为1、2、3,而默认的{h1}.{n}会在H1标题下印出Figure 1.1(资源类型)。每条题注下的说明写明数据是合成的。

#5 · 热力图和图例共用颜色

script.js · 第15–34行在完整代码中
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' } },
];

热力图的格子和图例的四个:swatch{color="…"}片段引用相同的调色板条目,所以给cool换一个值,热力图和图例会一起重新着色(行内格式)。图例是Results面板里的第二个:::columns分栏组,每栏两个条目。每种颜色同时写出十六进制值,1.4.1在色带的版面设计里绘制的就是这个值。

#6 · 用段落样式排核心数字

script.js · 第115–128行在完整代码中
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印出。另一个样式给参考文献设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.mdtitle: "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上的食谱文件夹 ↗

变化

#以150 dpi导出电子海报

会议要求更大的文件时,得到一张3,543 × 4,724 px的PNG,断行不变。

-  dpi: 72, backgroundColor: col('paper'),
+  dpi: 150, backgroundColor: col('paper'),

常见问题

易错点

:::columns只在框内起作用,且不会拆分

:::columns在标注框之外会被忽略,拆分的框也不会从columns组中间断开。breaks属性按子块计数,嵌套的框算一个。 框内分栏 →

易错点

嵌套框忽略span、placement和snapToGrid

嵌套在另一个标注框中的标注框会忽略自己的span、placement、snapToGrid和floatBarrier:它总是在父框内部排,宽度为父框的内宽。 嵌套框 →

易错点

设计文本不支持行内^sup^或**bold**

设计文本元素只打印纯文本,所以属性中的^1^或**bold**会原样出现。使用Unicode上标字符(¹ ² ³在latin子集中),或者再加一个字重不同的元素。 页面设计中的文字、线条和框 →

易错点

框内的表或图四周没有间距

在postext 1.4.1中,::resource在:::callout里以位置'here'排入的表或图,得不到它在正文中享有的间距:上下紧贴前后两段。在::resource这一行之前放一个:::space,后面还有文字时再在其后放一个;取一行的几分之一(如lines=0.33)就能留出一道小间隙。 图就放在这里 →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

段落样式没有斜体颜色

在postext 1.4.1中,段落样式可以设置color和boldColor,但没有italicColor:其中的斜体文字取bodyText.italicColor。于是一个淡色样式(小字、出处行)里的斜体书名会比周围的文字颜色更深。这类样式要么沿用正文的墨色,要么不用斜体。 段落样式 →

易错点

齐左文字从不检查孤字

optimalLineBreaking、avoidRunts、runtPenalty和runtMinCharacters作用于Knuth–Plass断行器,而postext 1.4.1只对两端对齐的文字运行它。齐左段落逐行断行,无论这些设置怎么写,都可能以一个短词结尾。检查齐左文字的末行,改写以孤字结尾的段落。 段末孤行、段首孤行与孤字 →

易错点

齐左文字可能让标点与粗体或:ref分开

在postext 1.4.1中,不两端对齐的文字(框内正文、齐左段落)可能在粗体或斜体片段(或:ref)与紧挨着它的标点之间断行:句号可能出现在下一行行首,引用前的'('可能留在上一行行尾。两端对齐的文字从不在这里断行。逐一检查每个版本中的框,遇到这种情况就改写句子,让这个片段落在行中间。 粗体、斜体及其颜色 →

易错点

不换行空格仍然会断行

在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →

易错点

替换调色板时,设计元素和引用颜色不会跟着变

postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据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在Canvas上会把括号里的单个数字画成带圈数字,例如11(3)里的期号,所以参考文献只给出卷号和页码。
  • 1.4.1中,位于文档末尾的通栏框下方若留不出两行正文的空间,就会移到新的一页。所以关键词长条用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)
沙盒