跳到主要内容
食谱编号4

排版食谱 · 第3章 · 标题与章首页

杂志专题:从照片首页到结束符

一个advancedDesign首页设计排出出血照片,以及标题的眉题、主标题、导语和署名;后面是两个浮动框和一个用行内标签做的结束符。

页码57 · 第1页,共4页

  • 英文样例:尚无中文版本
  • 成品尺寸225 × 297 mm
  • 2栏, 栏间距6 mm
  • Literata 9.6/13.4
  • Instrument Sans
  • Instrument Serif
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时49 ms
  • 248行代码

成品一览

虚构自然杂志《Boreal》冬季号的专题部分,页面尺寸225 × 297 mm。文章以一幅横贯页面上部出血的山间湖泊照片开篇。图片下方,加了字距的眉题压在两行主标题之上,主标题右侧是斜体导语,导语下面是署名;摄影师的出处排在照片下方。主标题就是Markdown标题的文字,眉题、导语、署名和出处都是它的属性。正文在一个跨页上接续,分两栏两端对齐。左页有一个悬挂引号的引文框,右栏顶部有一个资料框;右页上部有一条松林图带,页脚有一个深色数字栏。文章以一个小方块结束。翻过这页,一篇野外指南沿用同一个首页设计,换成插画和铁锈色眉题。

这道食谱解答

  • 怎样给章首页加作者行、提要或带首字下沉的导语?
  • 怎样给每章不同的照片、颜色或章首页样式?
  • 怎样排题词、献词、署名,或带大号引号的引文框?

简短回答

script.js · 第40–78行在完整代码中
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air,
        lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple)
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        overflow: 'wrap', // gotcha: overflow-ellipsis-default
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}

用料

类型
Literata, Instrument Serif, Instrument Sans(SIL OFL 1.1)
素材
  • lake-2000.jpg
  • thaw-2000.jpg
  • Clouds mirrored in a mountain lake (the opener photograph) (Ales Krivec, CC0 1.0)
  • Walk in a thawing forest (the photo band) (Hannah Donze, CC0 1.0)

做法

#1 · 用标题行搭出首页

代码见上面的简短回答。每个{attr.<key>}印出文章#行上<key>="…"的值,{titleText}印出标题本身(标题属性),所以每篇文章的文字都留在自己的Markdown里,所有一级标题共用一个设计。照片挂在页面顶边,容器从下方22 mm处开始,所以depth - TOP就是照片下边缘相对容器的位置;出处在这条线下方2 mm处靠右上,眉题在其下方9 mm处靠左上。主标题用below排在眉题之下,宽度为118 mm的HEAD行长。导语用right-of排在主标题右侧,不设宽度,因此在容器边缘折行;署名用below排在导语之下,导语变长时会把署名往下推,而不会压到它。这一层不设minHeight。主标题和署名各以一段5 mm的空白内边距结尾,这段内边距计入首页高度,所以不论两者各排几行,正文都从两者中较低那个下方至少5 mm处的第一条网格线开始。

#2 · 按真实像素一次性声明图片

script.js · 第212–243行在完整代码中
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];

设计元素按资源id指定图片,所以首页的照片像普通图一样声明,但从不被引用:只有首页设计绘制它,并一直画到裁切边,浮动体到不了那里。位图按页面的150 dpi计量,所以松林图的2000 px合339 mm,缩到195 mm行长后,印刷精度约260 dpi。这些图片有自己的资源类型,题注前缀和编号模板都为空,所以松林图的题注不带图号。图带是一个通页宽的top浮动体,而顶部浮动体会放到引用它的那一页的下一页。它的::resource行在左页、1944年那一段之后,所以图带排在右页顶部。

#3 · 把引号悬挂到页边

script.js · 第113–131行在完整代码中
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };

字形图标在文字旁边自占一栏,这会让引文缩进。负的间距把文字挪回到字形方框右侧的空白上。再设一个负的左内边距,宽度等于这一栏剩下的部分(字号加间距,34 pt),把这一栏移到页边里,文字就重新从栏边开始。框的边框仍留在栏内,所以背景色会在引号之前截止。方框从栏外12 mm处开始,居中其中的引号伸入左页14 mm外侧页边约6 mm。西班牙语版把«排得小一些,因为角引号比弯引号位置更低、也更宽。

#4 · 把框浮动到栏顶和页脚

script.js · 第135–159行在完整代码中
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces
// prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes
// the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink,
// not lake: the guide's palette would leave a lake chip teal on its rust page.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square

设置placement: 'top'后,资料框成为浮动体:它离开正文流,放到下一个空闲的栏顶,正文就一直排到栏底,不会在框放不下的地方留空。数字栏往另一个方向浮动,到页面底部,并横跨两栏;其中的三个数字是四级标题(文章从不用这一级),由breaks="3,5"分配位置,这个参数按子块计数,而不是按行。结束符是一个没有可见文字的行内标签。它的paddingX决定方块的宽度,行内标签的字号决定高度,两者都是行内标签自身字号的1.05 em。

#5 · 写出期号和文章名的书眉

script.js · 第82–109行在完整代码中
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot

页眉元素画在页面上,不占空间,所以每一部分都用毫米偏移锚定在页面上边距里。左页写出frontmatter中的杂志名和期号,右页写专题的标题;pages: 'body'让两者都不出现在首页上,首页只在页脚印页码。设计文本的基线位于行框从上往下0.8处,行框高为字号的1.2倍,所以baseline()把7.5 pt的标签比8.5 pt的页码下移0.34 mm,并让方块立在同一条线上。改动任一字号后,三部分仍共用一条基线。

#6 · 下一篇沿用同一个首页

script.js · 第163–166行在完整代码中
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };

首页设计是一个函数,所以野外指南通过标题样式# Five Kinds of Ice {style="guide" kicker="Field guide" …}得到同样的设计,只换了图片和深度。插画向下出血126 mm,而不是160 mm;出处和眉题从depth - TOP起算,其余文字都挂在眉题下,所以它们一起上移34 mm,指南的正文也开始得更高。这个样式的调色板在这几页把lake映射为铁锈色,于是眉题以及所有链接到lake的小标题和框都变成铁锈色,不必给opener()传颜色参数;行内标签不跟随调色板,所以结束符用墨色。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 004 · Magazine feature: photo opener to end mark ═══════
// https://postext.dev/en/cookbook/magazine-feature-opener
// Code: MIT · Text: original (CC BY 4.0) · Photos: Ales Krivec, Hannah Donze (CC0)
// Fonts: Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A nature feature from a winter issue. The level-1 heading carries its kicker, standfirst,
// byline and photo credit as attributes, and one opener design lays them out under a bleed
// photograph; the story runs on with a pull quote, a fact box, a photo band, a numbers panel
// and an end mark, and the next item reuses the opener with a drawing in place of the photo.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'magazine-feature-opener';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#15191c', // text: a blue-black
  lake: '#2d6a7d', // the accent: kickers, crossheads, the quote, the fact box's stripe
  ember: '#c8773d', // the panel's figures: 5.2:1 on ink (only 3.4:1 on paper)
  rust: '#9a5a2e', // the field guide's accent, swapped in for 'lake' by its heading style
  ice: '#dbe8ec', // the type on the dark panel and the drawing's sky
  rule: '#c7cdd1', // the drawing's far ridge and air bubbles
  muted: '#66707a', // running heads, credits, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'lake (defaults)', value: { hex: palette.lake, model: 'hex' } },
];
const TRIM = 225; // page width in mm, shared with the drawing
const INNER = 16; // inner margin in mm
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.4; // body leading in pt: the baseline grid

// #region answer: a bleed photo, then the heading's kicker, headline, standfirst, byline and credit
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air,
        lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple)
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        overflow: 'wrap', // gotcha: overflow-ellipsis-default
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}
// #endregion

// #region heads: magazine and issue on the verso, the story on the recto, a lake square
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot
// #endregion

// #region quote: a pull quote whose mark hangs in the margin, outside the text's edge
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };
// #endregion

// #region boxes: a fact box at a column head, a dark panel at the page foot, the end mark
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces
// prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes
// the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink,
// not lake: the guide's palette would leave a lake chip teal on its rust page.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square
// #endregion

// #region guide: the next item reuses the opener with its own picture, depth and accent
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: [photoType], // one unnumbered type for every picture (see the resources)
  colorPalette,
  page: { width: mm(TRIM), height: mm(297), margins: { top: mm(TOP), bottom: mm(20),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // a magazine trim; left is the inner side
    dpi: 150 }, // the layout's pixels per inch, which bitmaps are measured in (see 'thaw')
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'Literata', fontSize: pt(9.6), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.65, // a space never shrinks below 65 % (the default allows 60 %)
    runtMinCharacters: 40 }, // 40 spaces' width, about 20 letters: no one-word last lines
  // Hyphenation, optimal line breaking and widow control are on by default.
  headings: {
    fontFamily: 'Instrument Serif', fontWeight: 400, color: col('ink'),
    // Under a top photo band on a closing page, this lever can drop the shorter column a line,
    // out of line with the other (gotcha: float-stretch-closing-page). The switch covers every
    // page, not only closing ones; the shipped copy does not trip it, edited copy might.
    balancing: { stretchAfterFloats: false },
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break);
      // 'any' lets the next item open on the following page, recto or verso.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener('lake', 160) },
      { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), italic: true, color: col('lake'),
        marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above
      figures,
    ],
  },
  headingStyles: [guide],
  calloutStyles: [quote, glance, numbers],
  chipStyles: [endMark],
  captionStyle: { fontFamily: 'Instrument Sans', fontSize: pt(7.6), gap: mm(2), // ink: bodyText's
    note: { fontSize: pt(6.5), color: col('muted'), gap: mm(0.6) } },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Instrument Sans', fontSize: pt(6.6),
    lineHeight: pt(9.4), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(2 * LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region resources: two photographs and a drawing, declared once, by their real pixels
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];
// #endregion

const markdown = String.raw`---
Markdown样例 · 107行 · content.en.mdtitle: "Boreal" subtitle: "Winter 2026" --- # The Lake That Keeps Time {kicker="Climate · Field report" standfirst="For a hundred and fifteen winters, one family has written down the day their lake froze and the day it let go. Their notebooks are now one of the longest climate records in the mountains." byline="Words by Ingrid Solberg" credit="Late October, before the freeze · Photograph: Ales Krivec, CC0"} The notebook lives in a biscuit tin on the kitchen dresser, between the matches and the parish calendar. It is the fourth of its kind. The first three are in the regional archive now, wrapped in acid-free paper, but Hanna Brenner still prefers the tin. “The archive asked for this one as well,” she says, lifting the lid. “I told them we are still using it.” Her great-grandfather ran the ferry across the lake from 1906, and he began the record for a practical reason: a man who rows passengers over water needs to know when the water will carry a sledge instead. On the ninth of December 1911 he wrote, in pencil, *Frozen. All of it.* On the fourth of April he noted that the ice had gone out overnight, with a noise “like a door slammed somewhere in the mountains”. Every winter since, someone in the family has kept up the record, one line in pencil for every year. One hundred and fifteen lines in pencil do not look like much. Printed out, they fit on two sheets of paper. But the nearest weather station, in the next valley, opened only in 1931, and few lakes anywhere have an ice record as long as this one. The oldest, on Lake Suwa in Japan, was begun by Shinto priests in 1443 and notes the day a ridge of ice forms across the lake. ## A thermometer with a lid “A lake is a thermometer that you read once a year,” says Vera Lind, a limnologist who has spent six winters on the ice here. “The air changes from hour to hour. The lake averages all of that. When it freezes and when it thaws tells you what the whole season was like.” The reason lies in an odd habit of water. Most liquids grow denser as they cool. Fresh water does too, but only down to about 4 °C; below that it becomes lighter again, which is why a lake freezes from the top down; ice, lighter still, floats on it. In autumn, as the surface chills, the cold water sinks and warmer water rises to take its place. The lake turns over, again and again, for weeks. Only when the whole column has reached 4 °C can the surface cool further without sinking, and only then, on a still, clear night, can it skin over. :::callout{type="pullquote"} *A person stood on the same jetty and looked at the same water.* ::: That is why the freeze comes late and all at once. Hanna remembers standing on the jetty as a girl and watching the last open water close in the middle of the lake “like a pupil shrinking in the light”. By morning her father was walking out with an axe to measure the thickness. Ten centimetres will hold a person, the family rule went; twenty will hold the sledge; thirty, a horse. ## What the ice is made of The first ice to form is black ice, grown straight down from the water beneath it and so clear that you can see stones on the bottom through a hand’s width of it. It is the strongest ice a lake makes. Snow brings a second kind. A heavy fall presses the sheet down until water seeps up through the cracks and soaks the snow into slush, which freezes into white ice, cloudy with trapped air and barely half as strong. Lind drills through both every week of the winter. The cores come up banded like a tree trunk, and she reads them the same way: a thick black layer means a cold, dry December; a stack of white bands means storm after storm. Under the ice the lake goes on living. The water at the bottom stays close to 4 °C all winter. The trout slow down but keep feeding. Light still passes through clear ice, and algae grow in the green gloom beneath it, which surprised the first scientists who went looking. :::callout{type="glance" title="At a glance"} **Altitude** 1,540 m above sea level **Area** 3.2 km², deepest point 63 m **Record kept since** the winter of 1911–12 **Ice cover, 1911–1960** 118 days a year on average **Ice cover, 1991–2025** 87 days a year on average **Winters with no ice** 2007 and 2020 ::: ## Counting the days For the first fifty years of the record, the lake stayed frozen for an average of 118 days a winter. Since 1991 the average has been 87. The freeze now comes about a fortnight later than it did in the ferryman’s time, and the ice goes out more than two weeks earlier. The change has not been smooth. Some winters of the 1960s, and again the winter of 2010, were as long as any in the notebooks. But the open winters are new. In 2007 and again in 2020 the lake never froze across at all, and the family wrote a single word for the year: *open*. The winter of 1944 nearly went unrecorded. The ferryman’s son was away at the war, and the dates for that year are in another hand, small and upright, with a note in the margin: *kept by his mother*. She had written them on the back of a ration card and copied them in when he came home. Lind has checked them against the weather station in the next valley. They are, she says, as good as any in the notebooks. ::resource{id="thaw"} Lind is careful about what one lake can say. A single record is a local story: the valley has its own winds, and the lake its own depth and shape. But the notebooks agree with hundreds of other lakes across the northern hemisphere, from Finland to Japan, where the ice seasons have shortened by weeks over the past century. “I trust it because the method never changed,” she says. “A person stood on the same jetty and looked at the same water.” She has added instruments of her own. A chain of temperature loggers hangs from a buoy over the deepest part of the lake, reading every fifteen minutes from the surface to the floor, and a camera on the church tower photographs the ice at noon each day. But when she sets them beside the notebooks, the dates hold up: the family has never been more than a day or two from what the loggers record. ## Ice-out The ice rarely leaves quietly. Through March the sun works on it from above and warmer streams from below, and the black ice rots into long vertical crystals, candle ice, that chime against each other when the wind moves them. Then a warm rain or a south wind breaks the sheet, and within a day or two the surface is open. The old fishermen claimed they could hear it happen from the village. The date matters to more than the ferry. The spring bloom of algae, which feeds everything else in the lake, starts when the ice breaks up and sunlight pours into the water. Perch spawn in the shallows and midges hatch on a timetable that the ice sets. When ice-out moves earlier, those timetables can drift apart, and a young fish may hatch into water whose food has already come and gone. :::callout{type="numbers" title="In numbers"} :::columns{count=3 breaks="3,5"} #### 31 fewer days of ice each winter than in the first fifty years of the notebooks #### 115 winters recorded, in pencil, by five generations of one family #### 4 °C the temperature of the lake floor all winter, where water is densest ::: ::: There are human timetables too. The winter road across the lake, which once carried hay, timber and the doctor’s sleigh, has not been opened officially since 2014. The skating club moved its races to an artificial rink in the town. The ice fishermen still go out, but later in the season, and they carry ropes. Hanna’s grandfather remembered the other extreme. In the hard winter of 1963 the ice grew to sixty centimetres, and a baker from the next village drove his van straight across the lake to save the long road round. The entry for that year has a small drawing of the van in the margin, the only picture in all four notebooks. Hanna is not sentimental about any of this. She teaches mathematics at the valley school, and she has turned the notebooks into a lesson: every class plots the two dates of each winter and draws a line through the dots. “The children always find the trend by themselves,” she says. “I hand out graph paper and a ruler and say nothing about climate.” Some of her pupils have gone further. Two years ago a class set the notebooks beside the school’s own record of when the cherry trees in the yard came into flower, and found that both dates had moved by about the same number of days. Lind now shows their chart at conferences, with the children’s names in the corner. The record will go on. Hanna’s son, who is fourteen, has taken over the November walks to the jetty to watch for the morning when the last dark patch of water disappears. He keeps a spreadsheet now, and every night a copy goes to a server at the university, where Lind stores her logger readings. But on the day the lake freezes he does what his great-great-grandfather did in 1911 and writes the date in pencil, in the notebook in the tin. Last winter the lake froze on the twenty-first of December and opened again on the eighteenth of March: eighty-seven days, almost exactly the modern average. Hanna wrote *ordinary* beside the dates, then crossed the word out. “Ordinary for now,” she says. :chip[⁠]{style="end"} # Five Kinds of Ice {style="guide" kicker="Field guide" standfirst="How to read a frozen lake before you trust it with your weight, from the clear black sheet of early winter to the rotten candles of March." byline="Text by the editors" credit="Illustration generated in code for Boreal"} **Black ice.** The first ice of the season grows straight down from calm water in long crystals that let the light through. It looks dark because you are seeing the lake beneath it. Ten centimetres of new black ice will bear a walker, and on a calm morning you can watch fish pass under your boots. **White ice.** When snow loads the sheet, water seeps up through the cracks, soaks the snow and freezes into a milky layer full of air. It is roughly half as strong as black ice, so count it at half its thickness when you judge a crossing. On a sunny afternoon it softens further. **Slush.** A heavy snowfall can push the ice below the waterline and leave a layer of wet snow on top, kept liquid under an insulating crust. It is heavy going on foot and treacherous on skis, and it is where most white ice begins. Grey patches on fresh snow are its warning sign. **Shore ice.** The ice along the edge is the first to form and the first to go. Springs, reeds and the warmth of the ground weaken it, and by March a strip of open water, the moat, often separates the sheet from the land. You can walk out on good ice and find that you cannot walk back. **Candle ice.** In spring the sun rots the black ice along the boundaries of its crystals. The sheet can still look solid while it has become a bundle of loose vertical rods that give way under a boot. When the surface turns grey and granular, and the rods chime in the wind, stay on the shore. Never judge ice by its colour alone. Measure it every few steps, carry a pair of ice picks round your neck, and ask the people who live by the shore. :chip[⁠]{style="end"} :::paragraphs{style="colophon"} Set in Literata, Instrument Serif and Instrument Sans (SIL Open Font License) · Text: Postext Cookbook, CC BY 4.0 · Photographs: Ales Krivec and Hannah Donze, CC0, via Wikimedia Commons · The lake, the family, the scientists and the writer are fictional. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the guide's picture, a lake in section, drawn in code with a seeded PRNG function mulberry32(seed) { 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; }; } function iceArt() { // in mm, TRIM × ART: sky, shore, snow, white ice, black ice, water const rand = mulberry32(14); const f = (id, a = 1) => `fill="${palette[id]}"${a < 1 ? ` fill-opacity="${a}"` : ''}`; const rect = (x, y, w, h, paint) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${paint}/>`; const ridge = (base, amp, step, paint) => { // a mountain line, closed down to the shore let d = `M0 ${base}`; for (let x = 0; x <= TRIM; x += step) d += `L${x} ${(base - rand() * amp).toFixed(1)}`; return `<path d="${d}L${TRIM} 60L0 60Z" ${paint}/>`; }; const pines = Array.from({ length: 46 }, (_, i) => { // the far shore, a row of spruces const x = i * 5 + rand() * 3; const h = 4 + rand() * 5; return `<path d="M${x.toFixed(1)} ${60 - h}l${h * 0.28} ${h}h${-h * 0.56}Z" ` + `${f('ink', 0.85)}/>`; }).join(''); const bubbles = Array.from({ length: 70 }, () => { // air trapped in the white ice const r = 0.25 + rand() * 0.6; const [cx, cy] = [(rand() * TRIM).toFixed(1), (72 + rand() * 6).toFixed(1)]; return `<circle cx="${cx}" cy="${cy}" r="${r}" ${f('paper')} stroke="${palette.rule}" ` + 'stroke-width="0.15"/>'; }).join(''); // Black ice: a solid sheet on the winter side (left) that rots into candles towards spring, // ten rods evenly spaced, each shorter and thinner than the last. const candles = Array.from({ length: 10 }, (_, i) => rect(150 + i * 7.5, 79, (2.6 - i * 0.1).toFixed(2), (12.5 - i * 0.55 - rand() * 1.5).toFixed(1), f('ink'))).join(''); const clouds = [[18, 15, 52], [98, 8, 38], [146, 21, 30]].map(([x, y, w]) => [[x, y, w], [x + w * 0.22, y - 2.4, w * 0.42]].map(([cx, cy, cw]) => `<rect x="${cx}" y="${cy}" ` + `width="${cw}" height="4.4" rx="2.2" ${f('paper', 0.7)}/>`).join('')).join(''); const fish = (x, y, s) => `<path d="M${x} ${y}c${3 * s} ${-2 * s} ${7 * s} ${-2 * s} ${9 * s} 0` + `c${-2 * s} ${2 * s} ${-6 * s} ${2 * s} ${-9 * s} 0Z` // the body, then the tail + `m0 0l${-2.5 * s} ${-1.6 * s}v${3.2 * s}Z" ${f('ink', 0.55)}/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM * PX}" height="${ART * PX}" ` + `viewBox="0 0 ${TRIM} ${ART}">` + rect(0, 0, TRIM, ART, f('ice')) + clouds // winter sky + `<g transform="translate(0 ${ART - 112})">` // the lake keeps to the foot of the picture + `<circle cx="188" cy="24" r="9" ${f('ember', 0.9)}/>` // a low March sun + ridge(38, 16, 9, f('rule')) + ridge(48, 12, 6, f('muted', 0.55)) + pines + rect(0, 60, TRIM, 11, f('paper')) + rect(0, 60, TRIM, 11, f('ice', 0.3)) // snow + rect(0, 71, TRIM, 8, f('ice', 0.75)) + bubbles // white ice, cloudy with air + rect(0, 79, TRIM, 33, f('lake')) // the water, 4 °C at the floor + rect(0, 79, 146, 13, f('ink')) + candles + fish(60, 101, 1) + fish(128, 106, 0.8) + '</g></svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Literata: ['400', '400i', '700'], 'Instrument Serif': ['400', '400i'], 'Instrument Sans': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([loadImage('lake-2000.jpg', asset('lake-2000.jpg')), loadImage('thaw-2000.jpg', asset('thaw-2000.jpg')), loadSvg('ice-art.svg', iceArt())]); const continuation = { pageNumbering: { startAt: 57 } }; // pages 57–60 of the issue const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Magazine feature: photo opener to end mark', es: 'Reportaje de revista: de la foto de apertura al signo final' }) });
工具包 · 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上的食谱文件夹 ↗

变化

#每一篇都从右页开始

改为'odd'后,野外指南移到第61页,第60页留空。

- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },

#把标题放在色带上

教科书的章节用色带和大号数字代替照片,见出血色带上的章首页。

常见问题

易错点

属性值:不能含{ or };含"的值用单引号

属性值在右花括号处结束,所以不能包含{ or }。含双引号的值要放在单引号里;美元符号没有问题。 标题属性 →

易错点

位图按文档dpi以px排版:声明印刷尺寸

位图资源的尺寸取自它声明的宽和高,单位是文档dpi下的像素,而不是取自文件本身。声明印刷尺寸对应的像素数(按印刷宽度约300 dpi),图才会以正确的大小出现并保持清晰。 作为资源的图和表 →

易错点

'top'浮动体不会出现在引用它的那一页

浮动体不会排在自己的引用之前,所以在第N页引用的整页宽'top'浮动体会出现在第N+1页的顶部。把引用提前,或者使用position 'auto'或'bottom',它们可以占用引用页的底部。 图的放置 →

易错点

只含空白的行内标签会打印出标记

文字只有空格(包括不换行空格)的行内标签,会按原样打印出:chip[ ]。要做空白的答题标签,在里面放一个连接符(U+2060)。 行内标签 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

易错点

页眉和页脚元素画在正文之上

页眉和页脚元素画在页面上层,正文区域不会为它们让出位置。把它们放在页边距以内,为它们预留空间的正是页边距。 书眉与页码 →

易错点

设计文本的overflow默认为'ellipsis-end'

宽度放不下的设计文本元素默认以省略号结尾。需要折成多行的标题,设置overflow: 'wrap'。 页面设计中的文字、线条和框 →

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

在收尾页上,顶部浮动体可能把最后一栏往下推

在postext 1.4.1中,当一章或一篇文章结束在以整页宽顶部浮动体开头的页面上,而且各栏的行数分配不均时,stretchAfterFloats会在较短的一栏中、浮动体下方加一个空行,而不是让它提前结束,于是两栏不再从同一行开始。把headings.balancing.stretchAfterFloats设为false,或者把文字调整到偶数行。 各栏齐底 →

易错点

只有8种语言区域能断词,且须代码完全一致

断词支持en-us、es、fr、de、it、pt、ca和nl,须完全匹配:'es-ES'或其他任何语言都会悄悄退回美式英语。 断词与文档语言 →

易错点

设计文本的lineHeight是倍数,不是尺寸

在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →

易错点

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

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

沙盒检查 · bitmapTooSmall

图片分辨率过低

原因. 某张位图绘制的宽度超过其像素宽度的1.5倍,印刷出来会发虚。

解决. 提供按印刷尺寸约300 dpi的图片,并声明位图的真实宽度和高度。 文档 →

  • 图片元素把图片缩放到框内,从不裁切。把JPEG裁成框的比例,这里是225 × 160 mm,否则照片会缩小,页边留出白条。
  • 悬挂的引号需要页边。如果修改后引文框落到右栏,引号会填满6 mm的栏间距,碰到左栏的文字:把引文框往前或往后挪一段。
  • 文章经过字数调整,结束在第59页、数字栏之上。多出几行就会把结尾挤到单独一页,所以修改后要在两种语言中重新调整字数。
  • runtMinCharacters: 40避免最后一行只有一个词。断行算法无法避开很短的末行时,引擎会把段落少排一行,收紧词间距,不够时再把字距最多减小0.01 em。修改后,找出排得这么紧的段落,删减或增加几个词来解决;随附的文本在两种语言中都不需要这种修正。

致谢

文本
原创文字, CC BY 4.0
图片
字体
Literata (SIL OFL 1.1) · Instrument Serif (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
沙盒