跳到主要内容
食谱编号44

排版食谱 · 第2章 · 字体与文字

校勘本:行号与按行号编排的注释

按1645年文本排弥尔顿的《Lycidas》:一个脚本给诗行计数,每隔五行加一个侧框,每条注释以其行号开头,行号排成行内标签。

本页内容
类型
诗歌
输出
Canvas
难度
高级
Postext
已用Postext 1.6.0测试
需要≥ 1.4.1
许可证
更新于2026年9月28日
代码MIT · 文本CC BY 4.0
  • 英文样例:尚无中文版本
  • 成品尺寸138 × 216 mm
  • 一栏半, 栏间距4 mm
  • Linden Hill 10.5/14.5
  • Imbue
  • Libre Franklin
  • 9页
  • 难度
  • Postext 1.6.0
  • 排版用时38 ms
  • 194行代码

成品一览

Lycidas是弥尔顿悼念Edward King的挽歌,这里按1645年《Poems》的文本和拼写,排成一本138 × 216 mm的小型校勘本。第一页以一枝月桂开篇,标题是Imbue的瘦高大写字母,下面是弥尔顿1645年加上的题记。随后是193行诗,每行诗占一行,诗节之间空一行,短行缩进。每第五行在切口侧6 mm宽的栏里用月桂绿标出行号:右页在诗行右侧,左页在诗行左侧。诗行里不带注释标记。注释排在诗后的两页,每条以所在行的行号开头,用与页边行号相同的绿色Libre Franklin。

这道食谱解答

  • 怎样在诗的页边每隔五行标一个行号,并让注释对应这些行号?
  • 怎样排诗歌:每句一行,诗节间留空,折行悬挂缩进,不断词?
  • 脚注怎么做?
  • 怎样做一栏半版式,一个宽的正文栏加一个窄的侧栏?
  • 空行不起作用时,怎样在两个块之间加额外的垂直间距?

简短回答

script.js · 第33–63行在完整代码中
// The poem is written one line of verse to a line of Markdown, with a blank line between
// verse paragraphs and two spaces before a short line. numberVerse() gives each line a
// paragraph of its own and, after every fifth, a side box that holds its number. A side box
// stands where the text has reached at its fence, under the line it follows; a top padding
// of minus one line lifts the number back onto that line. Fenced before its line instead,
// the number of a line that opens a page slides up beside the last line of the page before
// (gotcha: side-box-starts-at-fence).
const EVERY = 5;
const rows = (...lines) => lines.join('\n');
function numberVerse(markdown) {
  return markdown.replace(/^:::paragraphs\{style="verse"\}\n([\s\S]*?)\n:::$/gm, (_, poem) => {
    let n = 0;
    return poem.split('\n').map((line) => {
      if (!line.trim()) return ':::space{lines=1}'; // one blank line of the grid
      n += 1;
      const style = line.startsWith('  ') ? 'short' : 'verse';
      const verse = rows(`:::paragraphs{style="${style}"}`, line.trim(), ':::');
      if (n % EVERY) return verse;
      return rows(verse, '', ':::callout{type="lineno" span="side"}',
        ':::paragraphs{style="number"}', n, ':::', ':::');
    }).join('\n\n');
  });
}
const lineno = { id: 'lineno', backgroundEnabled: false, // no box: only the number shows
  padding: { top: pt(-LEAD), right: pt(0), bottom: pt(0), left: pt(0) } };
// The number: right-aligned, so the numbers share a right edge, and at the verse's leading,
// so it sits on the baseline of its line.
const number = { id: 'number', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(LEAD),
  color: col('laurel'), textAlign: 'right' };
// Hook-up: calloutStyles: [lineno], paragraphStyles: [number, …] and
// buildDocument({ markdown: numberVerse(markdown) }, config()).

用料

类型
Linden Hill, Imbue, Libre Franklin(SIL OFL 1.1)
素材
  • The sprig of bay laurel on the first page, drawn in code in the page’s greens (Ignacio Ferro, CC BY 4.0)

做法

#1 · 排版前先数行

这一步的代码见上文的简短回答。Postext不给诗行编号,所以numberVerse()在构建前在Markdown里数行。它让每行诗自成一个段落,并在每第五行之后加一个装着行号的:::callout{type="lineno" span="side"}。侧框在侧栏里从正文排到其围栏处的高度开始,所以这个框从它所跟的那一行下方开始。样式的上内边距为负一行(−14.5 pt),把行号抬回到那一行旁边,框本身不占高度(标注框样式)。如果把框的围栏放在诗行之前,大多数页面上它会与该行平齐;但当带编号的行开启新的一页时,框会滑到上一页最后一行旁边。框内的number段落样式让数字右对齐,使行号共用一条右边缘,并给它们与诗行相同的14.5 pt行距,让每个行号都落在所在行的基线上。

#2 · 一条六毫米宽的窄栏

script.js · 第67–83行在完整代码中
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes side boxes, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };

设置sideColumnRole: 'floats'后,oneAndHalf版面的窄栏只放侧框,永远不排正文(版面类型)。sideColumnPercent是版心宽度的一个比例,所以CodePen示例用毫米换算:90 mm中的6 mm,足够放下最宽的行号。Libre Franklin的数字是比例宽度的,7.5 pt时0最宽(1.8 mm,1是1.2 mm),所以100宽4.9 mm,是这首诗里最宽的行号;180和190是4.8 mm。'outer'随镜像页边距而变,所以行号在右页位于诗行右侧,在左页位于左侧;在右页上,行号与上方的页码结束于同一条边。诗行栏宽80 mm,比最长的第31行宽1.9 mm,所以没有哪一行会转行。

#3 · 每行诗是一个段落

script.js · 第87–93行在完整代码中
const verseStyles = [
  // Ragged, like all the text here (bodyText). A line too long for the measure would turn
  // over and hang 2 em in; none does at 80 mm.
  { id: 'verse', hangingIndent: em(2) },
  // Milton's short lines: a one-line paragraph, so the first-line indent moves all of it.
  { id: 'short', firstLineIndent: em(2) },
];

Postext会把一个Markdown段落里的各行连起来,所以每行诗都需要自成一个段落,numberVerse()把每行放进一个带诗行样式的:::paragraphs容器(段落样式)。同一个样式无法既让一行缩进、又让它的转行悬挂,所以弥尔顿的十四个短行(第4行即其一)用单独的样式:在只有一行的段落里,2 em的首行缩进会让整行右移。诗节之间的空行变成:::space{lines=1},即网格的一行。它在页首会被丢弃(:::space),所以第2页从版心顶部的第15行开始,第14行与第15行之间的诗节分隔看不出来(见常见问题)。

#4 · 月桂、标题和题记

script.js · 第97–123行在完整代码中
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  // lineHeight is a multiple of the size (gotcha: design-lineheight-multiple).
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // An image element reserves no height (gotcha: opener-image-no-reserve): the kicker, title
  // and headnote under it reach down 20 lines. minHeight is a floor at the same depth, so a
  // shorter headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}. Design text wraps ragged and has no inline
  // italics (gotcha: design-text-no-inline-marks); at 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). span
// 'page' paints the laurel above the text block, where a column clips its design. With the
// default marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };

一级标题印出的是版面设计,而不是标题本身的文字(通栏与高级设计)。{author}来自前置元数据,题记来自标题行上的一个属性,# Lycidas {headnote="In this Monody …"}(标题属性)。月桂是一个图像元素,不预留高度,所以开篇的深度取决于其下的文字:眉题位于版心顶部以下50 mm,标题和四行题记一直延伸到本页34行中的第20行,第一个诗节14行,占满其余部分。minHeight是同一深度的下限:把题记删成三行,诗行仍从第21行开始,而没有这个下限,它会上移到第20行。marginBottom: pt(0)让标题的默认外边距不进入这段间距;若保留它,诗行会从第22行开始,把第14行推到第2页。这一级标题横跨整页,因为留在栏内的设计会在栏的上边缘被裁掉,而栏的上边缘在切边以下21 mm,月桂却是从切边开始的。

#5 · 按行号编排的注释

script.js · 第127–146行在完整代码中
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', span: 'column', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };

注释按行号编排,所以诗行里不带注释标记;注释作为评注排在诗后,不占诗页的行。每条注释以所在行的行号开头,写作:chip[8]{style="line"},然后是斜体的被释词语和一个右方括号。这个行内标签没有填充、描边和左右内边距,所以只改变字体和颜色:7.7 pt、月桂绿的Libre Franklin(标签样式)。Linden Hill没有粗体,所以行号靠换一种字体来区分。注释样式让首行之后的每行缩进1.6 em,使左侧的行号清晰可辨;注释与诗行一样齐左排。# Notes {style="notes"}另起到第8页,标题用开篇页的大写字母,30 pt。

Page 8: each note opens on the green number of its line; the verse on pages 1 to 7 carries no markers.

#6 · 按奇偶页区分的书眉

script.js · 第150–171行在完整代码中
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };

每个元素都锚定在页面上,并用parity和pages: 'body'过滤(文本元素)。左页印作者,右页印{chapterTitle},诗页上是LYCIDAS,注释页上是NOTES,因为注释以一个独立的标题开头。页码位于版心的外侧边缘;两个开篇页,即第1页和第8页,页码改排在页脚。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 044 · Critical edition: line numbers and line-keyed notes ═══
// https://postext.dev/en/cookbook/critical-edition-line-numbers
// Code: MIT · Text: Milton, Poems (1645) (PD) · Notes and laurel: CC BY 4.0
// Fonts: Linden Hill, Imbue, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Lycidas in the spelling of 1645, with a number beside every fifth line and two pages of
// notes keyed to those numbers, so the verse carries no note markers.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'critical-edition-line-numbers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Black text on white, and one laurel green for the apparatus.
const palette = {
  ink: '#1b1b1b', // the text
  laurel: '#3c5a3e', // line numbers, note numbers, the kicker; the laurel's leaves
  leaf: '#6d8a5f', // the leaves behind, in the drawing
  berry: '#a4a653', // unripe berries: 'harsh and crude' (line 3)
  muted: '#6a706a', // running heads, the colophon
  paper: '#ffffff',
};
// col(id) carries the hex beside the id, because design slots paint the hex
// (gotcha: palette-skips-designs).
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' } }));
const [TEXT, DISPLAY, LABEL] = ['Linden Hill', 'Imbue', 'Libre Franklin'];
const LEAD = 14.5; // pt: the leading of the verse, and the grid every page keeps

// #region answer: count the lines, and after every fifth set its number in the margin
// The poem is written one line of verse to a line of Markdown, with a blank line between
// verse paragraphs and two spaces before a short line. numberVerse() gives each line a
// paragraph of its own and, after every fifth, a side box that holds its number. A side box
// stands where the text has reached at its fence, under the line it follows; a top padding
// of minus one line lifts the number back onto that line. Fenced before its line instead,
// the number of a line that opens a page slides up beside the last line of the page before
// (gotcha: side-box-starts-at-fence).
const EVERY = 5;
const rows = (...lines) => lines.join('\n');
function numberVerse(markdown) {
  return markdown.replace(/^:::paragraphs\{style="verse"\}\n([\s\S]*?)\n:::$/gm, (_, poem) => {
    let n = 0;
    return poem.split('\n').map((line) => {
      if (!line.trim()) return ':::space{lines=1}'; // one blank line of the grid
      n += 1;
      const style = line.startsWith('  ') ? 'short' : 'verse';
      const verse = rows(`:::paragraphs{style="${style}"}`, line.trim(), ':::');
      if (n % EVERY) return verse;
      return rows(verse, '', ':::callout{type="lineno" span="side"}',
        ':::paragraphs{style="number"}', n, ':::', ':::');
    }).join('\n\n');
  });
}
const lineno = { id: 'lineno', backgroundEnabled: false, // no box: only the number shows
  padding: { top: pt(-LEAD), right: pt(0), bottom: pt(0), left: pt(0) } };
// The number: right-aligned, so the numbers share a right edge, and at the verse's leading,
// so it sits on the baseline of its line.
const number = { id: 'number', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(LEAD),
  color: col('laurel'), textAlign: 'right' };
// Hook-up: calloutStyles: [lineno], paragraphStyles: [number, …] and
// buildDocument({ markdown: numberVerse(markdown) }, config()).
// #endregion

// #region page: a poetry trim, and a channel for the numbers at the fore-edge
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes side boxes, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };
// #endregion

// #region verse: a paragraph per line, ragged, and the short lines set in
const verseStyles = [
  // Ragged, like all the text here (bodyText). A line too long for the measure would turn
  // over and hang 2 em in; none does at 80 mm.
  { id: 'verse', hangingIndent: em(2) },
  // Milton's short lines: a one-line paragraph, so the first-line indent moves all of it.
  { id: 'short', firstLineIndent: em(2) },
];
// #endregion

// #region opener: the laurel, the title in tall capitals, and the headnote of 1645
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  // lineHeight is a multiple of the size (gotcha: design-lineheight-multiple).
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // An image element reserves no height (gotcha: opener-image-no-reserve): the kicker, title
  // and headnote under it reach down 20 lines. minHeight is a floor at the same depth, so a
  // shorter headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}. Design text wraps ragged and has no inline
  // italics (gotcha: design-text-no-inline-marks); at 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). span
// 'page' paints the laurel above the text block, where a column clips its design. With the
// default marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };
// #endregion

// #region notes: each note opens on its line number, a chip in the label face
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', span: 'column', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };
// #endregion

// #region heads: the author on the verso, the section on the recto, folios at the fore-edge
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, page, layout, header, footer,
  bodyText: { // every paragraph sits in a styled container and takes these as defaults
    fontFamily: TEXT, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    // Ragged throughout, verse and notes alike, so nothing is hyphenated
    // (gotcha: ragged-no-hyphenation).
    textAlign: 'left', firstLineIndent: pt(0),
  },
  // The designs print the titles, but each heading's own text is still measured, in this face.
  // Left at the default, the page would fetch Open Sans 700 for text it never paints.
  headings: { fontFamily: DISPLAY, fontWeight: 300, levels: [poem] },
  headingStyles: [notesHead],
  paragraphStyles: [...verseStyles, number, ...noteStyles],
  calloutStyles: [lineno],
  chipStyles,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 211行 · content.en.mdtitle: "Lycidas" author: "John Milton" --- # Lycidas {headnote="In this Monody the Author bewails a learned Friend, unfortunatly drown’d in his Passage from Chester on the Irish Seas, 1637. And by occasion foretels the ruine of our corrupted Clergy then in their height."} :::paragraphs{style="verse"} Yet once more, O ye Laurels, and once more Ye Myrtles brown, with Ivy never-sear, I com to pluck your Berries harsh and crude, And with forc’d fingers rude, Shatter your leaves before the mellowing year. Bitter constraint, and sad occasion dear, Compels me to disturb your season due: For *Lycidas* is dead, dead ere his prime, Young *Lycidas*, and hath not left his peer: Who would not sing for *Lycidas?* he knew Himself to sing, and build the lofty rhyme. He must not flote upon his watry bear Unwept, and welter to the parching wind, Without the meed of som melodious tear. Begin then, Sisters of the sacred well, That from beneath the seat of *Jove* doth spring, Begin, and somwhat loudly sweep the string. Hence with denial vain, and coy excuse, So may som gentle Muse With lucky words favour my destin’d Urn, And as he passes turn, And bid fair peace be to my sable shrowd. For we were nurst upon the self-same hill, Fed the same flock, by fountain, shade, and rill. Together both, ere the high Lawns appear’d Under the opening eye-lids of the morn, We drove a field, and both together heard What time the Gray-fly winds her sultry horn, Batt’ning our flocks with the fresh dews of night, Oft till the Star that rose, at Ev’ning, bright Toward Heav’ns descent had slop’d his westering wheel. Mean while the Rural ditties were not mute, Temper’d to th’ Oaten Flute, Rough *Satyrs* danc’d, and *Fauns* with clov’n heel, From the glad sound would not be absent long, And old *Damœtas* lov’d to hear our song. But O the heavy change, now thou art gon, Now thou art gon, and never must return! Thee Shepherd, thee the Woods, and desert Caves, With wilde Thyme and the gadding Vine o’regrown, And all their echoes mourn. The Willows, and the Hazle Copses green, Shall now no more be seen, Fanning their joyous Leaves to thy soft layes. As killing as the Canker to the Rose, Or Taint-worm to the weanling Herds that graze, Or Frost to Flowers, that their gay wardrop wear, When first the White thorn blows; Such, *Lycidas*, thy loss to Shepherds ear. Where were ye Nymphs when the remorseless deep Clos’d o’re the head of your lov’d *Lycidas*? For neither were ye playing on the steep, Where your old *Bards*, the famous *Druids* ly, Nor on the shaggy top of *Mona* high, Nor yet where Deva spreads her wisard stream: Ay me, I fondly dream! Had ye bin there—for what could that have don? What could the Muse her self that *Orpheus* bore, The Muse her self, for her inchanting son Whom Universal nature did lament, When by the rout that made the hideous roar, His goary visage down the stream was sent, Down the swift *Hebrus* to the *Lesbian* shore. Alas! What boots it with uncessant care To tend the homely slighted Shepherds trade, And strictly meditate the thankles Muse, Were it not better don as others use, To sport with *Amaryllis* in the shade, Or with the tangles of *Neæra*’s hair? Fame is the spur that the clear spirit doth raise (That last infirmity of Noble mind) To scorn delights, and live laborious dayes; But the fair Guerdon when we hope to find, And think to burst out into sudden blaze, Comes the blind *Fury* with th’ abhorred shears, And slits the thin spun life. But not the praise, *Phœbus* repli’d, and touch’d my trembling ears; *Fame* is no plant that grows on mortal soil, Nor in the glistering foil Set off to th’ world, nor in broad rumour lies, But lives and spreds aloft by those pure eyes, And perfet witnes of all judging *Jove*; As he pronounces lastly on each deed, Of so much fame in Heav’n expect thy meed. O Fountain *Arethuse*, and thou honour’d flood, Smooth-sliding *Mincius*, crown’d with vocall reeds, That strain I heard was of a higher mood: But now my Oate proceeds, And listens to the Herald of the Sea That came in *Neptune*’s plea, He ask’d the Waves, and ask’d the Fellon winds, What hard mishap hath doom’d this gentle swain? And question’d every gust of rugged wings That blows from off each beaked Promontory, They knew not of his story, And sage *Hippotades* their answer brings, That not a blast was from his dungeon stray’d, The Ayr was calm, and on the level brine, Sleek *Panope* with all her sisters play’d. It was that fatall and perfidious Bark Built in th’ eclipse, and rigg’d with curses dark, That sunk so low that sacred head of thine. Next *Camus*, reverend Sire, went footing slow, His Mantle hairy, and his Bonnet sedge, Inwrought with figures dim, and on the edge Like to that sanguine flower inscrib’d with woe. Ah! Who hath reft (quoth he) my dearest pledge? Last came, and last did go, The Pilot of the *Galilean* lake, Two massy Keyes he bore of metals twain, (The Golden opes, the Iron shuts amain) He shook his Miter’d locks, and stern bespake, How well could I have spar’d for thee young swain. Anow of such as for their bellies sake, Creep and intrude, and climb into the fold? Of other care they little reck’ning make, Then how to scramble at the shearers feast, And shove away the worthy bidden guest. Blind mouthes! that scarce themselves know how to hold A Sheep-hook, or have learn’d ought els the least That to the faithfull Herdmans art belongs! What recks it them? What need they? They are sped; And when they list, their lean and flashy songs Grate on their scrannel Pipes of wretched straw, The hungry Sheep look up, and are not fed, But swoln with wind, and the rank mist they draw, Rot inwardly, and foul contagion spread: Besides what the grim Woolf with privy paw Daily devours apace, and nothing sed, But that two-handed engine at the door, Stands ready to smite once, and smite no more. Return *Alpheus*, the dread voice is past, That shrunk thy streams; Return *Sicilian* Muse, And call the Vales, and bid them hither cast Their Bels, and Flourets of a thousand hues. Ye valleys low where the milde whispers use, Of shades and wanton winds, and gushing brooks, On whose fresh lap the swart Star sparely looks, Throw hither all your quaint enameld eyes, That on the green terf suck the honied showres, And purple all the ground with vernal flowres. Bring the rathe Primrose that forsaken dies. The tufted Crow-toe, and pale Gessamine, The white Pink, and the Pansie freakt with jeat, The glowing Violet. The Musk-rose, and the well attir’d Woodbine, With Cowslips wan that hang the pensive hed, And every flower that sad embroidery wears: Bid *Amaranthus* all his beauty shed, And Daffadillies fill their cups with tears, To strew the Laureat Herse where *Lycid* lies. For so to interpose a little ease, Let our frail thoughts dally with false surmise. Ay me! Whilst thee the shores and sounding Seas Wash far away, where ere thy bones are hurld, Whether beyond the stormy *Hebrides*, Where thou perhaps under the whelming tide Visit’st the bottom of the monstrous world; Or whether thou to our moist vows deny’d, Sleep’st by the fable of *Bellerus* old, Where the great vision of the guarded Mount Looks toward *Namancos* and *Bayona*’s hold; Look homeward Angel now, and melt with ruth. And, O ye *Dolphins*, waft the haples youth. Weep no more, woful Shepherds weep no more, For *Lycidas* your sorrow is not dead, Sunk though he be beneath the watry floar, So sinks the day-star in the Ocean bed, And yet anon repairs his drooping head, And tricks his beams, and with new-spangled Ore, Flames in the forehead of the morning sky: So *Lycidas* sunk low, but mounted high, Through the dear might of him that walk’d the waves; Where other groves, and other streams along, With *Nectar* pure his oozy Lock’s he laves, And hears the unexpressive nuptiall Song, In the blest Kingdoms meek of joy and love. There entertain him all the Saints above, In solemn troops, and sweet Societies That sing, and singing in their glory move, And wipe the tears for ever from his eyes. Now *Lycidas* the Shepherds weep no more; Hence forth thou art the Genius of the shore, In thy large recompense, and shalt be good To all that wander in that perilous flood. Thus sang the uncouth Swain to th’ Okes and rills, While the still morn went out with Sandals gray, He touch’d the tender stops of various Quills, With eager thought warbling his Dorick lay: And now the Sun had stretch’d out all the hills, And now was dropt into the Western bay; At last he rose, and twitch’d his Mantle blew: To morrow to fresh Woods, and Pastures new. :::
`; // content.<lang>.md, inlined by the Cookbook: the poem const notes = String.raw`# Notes {style="notes"}
Markdown样例 · 72行 · content.notes.en.md :::paragraphs{style="textnote"} *The text* is that of *Poems of Mr. John Milton* (London, 1645), pages 57 to 65. Lycidas had first been printed, without the headnote, in *Justa Edovardo King naufrago* (Cambridge, 1638), the volume of elegies for King. Spelling and capitals follow 1645, and so does the italic of names in the poem. A verse paragraph that 1645 marks by indenting its first line is marked here by a blank line; the short lines are indented. The notes are keyed to the line numbers in the margin. ::: :::paragraphs{style="note"} :chip[1]{style="line"} *Yet once more*] Hebrews 12.26, ‘Yet once more I shake not the earth only, but also heaven.’ :chip[1–2]{style="line"} *Laurels … Myrtles … Ivy*] evergreens and poets’ crowns: the laurel is Apollo’s, the myrtle Venus’s, the ivy Bacchus’s. :chip[3]{style="line"} *crude*] unripe (Latin *crudus*). The berries are picked before their season, as King died before his. :chip[8]{style="line"} *Lycidas*] Edward King (1612–1637), fellow of Christ’s College, Cambridge, drowned on 10 August 1637 when his ship, bound from Chester for Dublin, struck a rock off the Welsh coast. Lycidas is a herdsman in Theocritus, *Idyll* 7, and in Virgil, *Eclogue* 9. :chip[12]{style="line"} *flote … bear*] float … bier. :chip[15]{style="line"} *Sisters of the sacred well*] the Muses, who dance round the spring and the altar of Zeus on Helicon in the first lines of Hesiod’s *Theogony*. :chip[23]{style="line"} *the self-same hill*] Christ’s College, Cambridge, where Milton studied from 1625 to 1632 and King from 1626. :chip[36]{style="line"} *Damœtas*] a herdsman in Theocritus and Virgil. Some commentators see in him a tutor of Christ’s, William Chappell or Joseph Mede; neither identification is certain. :chip[53–55]{style="line"} *Druids … Mona … Deva*] the Druids’ island is Anglesey, *Mona* in Tacitus, *Annals* 14.30; the Dee (*Deva*) reaches the sea below Chester, where King sailed. Its shifting course was read as an omen for England and Wales, hence *wisard*. :chip[58]{style="line"} *the Muse her self that Orpheus bore*] Calliope. The women of Thrace tore Orpheus apart, and his head, still singing, went down the Hebrus and over the sea to Lesbos (Ovid, *Metamorphoses* 11.1–55). :chip[64]{style="line"} *uncessant*] unceasing; so 1638, 1645 and 1673. :chip[70–71]{style="line"} *That last infirmity of Noble mind*] Tacitus, *Histories* 4.6: the desire for glory is the last thing even the wise put off. :chip[75]{style="line"} *the blind Fury*] Atropos, the Fate who cuts the thread of life; Milton makes her a Fury, and blind. :chip[77]{style="line"} *touch’d my trembling ears*] Apollo plucks the poet’s ear in Virgil, *Eclogue* 6.3–4, to call him back from kings and battles to pastoral. :chip[85–86]{style="line"} *Arethuse … Mincius*] the fountain of Syracuse and the river of Mantua: the country of Theocritus and the country of Virgil. :chip[96]{style="line"} *Hippotades*] Aeolus, son of Hippotes, keeper of the winds. :chip[103]{style="line"} *Camus*] the god of the Cam, who stands for Cambridge; he walks as slowly as his river flows. :chip[106]{style="line"} *that sanguine flower*] the hyacinth, sprung from the blood of Hyacinthus, whose petals were said to carry AI, a cry of grief (Ovid, *Metamorphoses* 10.215). :chip[109]{style="line"} *The Pilot of the Galilean lake*] St Peter, the fisherman given the keys of heaven (Matthew 16.19), mitred here as the first bishop. :chip[114–117]{style="line"} *Anow … Then*] enough … than. :chip[119]{style="line"} *Blind mouthes*] Ruskin, in *Sesame and Lilies* (1865): a bishop is one who sees and a pastor one who feeds, so a blind mouth is a clergyman who does neither. :chip[128]{style="line"} *the grim Woolf*] usually read as the Church of Rome, which was making converts at the court of Charles I. :chip[130]{style="line"} *that two-handed engine*] no reading has settled it. Readers have proposed the sword of the archangel Michael, the axe laid to the root of the trees in Matthew 3.10, the two Houses of Parliament and St Peter’s two keys. :chip[132]{style="line"} *Alpheus*] the river said to run under the sea from Greece and rise in Arethusa’s fountain (line 85). Called on here, it brings the poem back to pastoral after St Peter’s speech. :chip[138]{style="line"} *the swart Star*] Sirius, the Dog Star of the hottest weeks, which scorches what it looks on. :chip[156]{style="line"} *Hebrides*] King’s body was never found. :chip[160–162]{style="line"} *Bellerus … the guarded Mount … Namancos*] Bellerus is made from Bellerium, the Roman name of Land’s End. From St Michael’s Mount the archangel looks out over the sea to Galicia, where Mercator’s atlas marks Namancos, near the castle of Bayona. :chip[164]{style="line"} *Dolphins*] like the dolphin that carried the singer Arion to shore at Taenarum (Herodotus 1.24). :chip[176]{style="line"} *unexpressive nuptiall Song*] the song past expressing at the marriage of the Lamb (Revelation 19.7–9). :chip[183]{style="line"} *Genius of the shore*] the guardian spirit of a place: King will keep those who cross the sea he drowned in. :chip[193]{style="line"} *fresh Woods*] in the spring of 1638 Milton left England for Italy. ::: :::paragraphs{style="colophon"} Set in Linden Hill, Imbue and Libre Franklin (SIL Open Font License). Text of 1645, public domain. Notes and laurel drawing, CC BY 4.0. :::
`; // content.notes.<lang>.md: the notes // #region art: a sprig of bay laurel with its unripe berries, in the page's greens let seed = 1645; // Mulberry32: a seeded generator, never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const f1 = (v) => v.toFixed(1); const ring = (pts) => `M${pts.map(([x, y]) => `${f1(x)} ${f1(y)}`).join('L')}Z`; const line = (pts) => `M${pts.map(([x, y]) => `${f1(x)} ${f1(y)}`).join('L')}`; const fill = (d, hex) => `<path d="${d}" fill="${hex}"/>`; const stroke = (d, hex, w) => `<path d="${d}" fill="none" stroke="${hex}" stroke-width="${w}" ` + 'stroke-linecap="round"/>'; const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2), 16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')) .join('')}`; // A point on a cubic Bézier, with its direction. const bez = ([p0, p1, p2, p3], t) => { const u = 1 - t; const pos = (i) => u * u * u * p0[i] + 3 * u * u * t * p1[i] + 3 * u * t * t * p2[i] + t * t * t * p3[i]; const d = (i) => 3 * u * u * (p1[i] - p0[i]) + 6 * u * t * (p2[i] - p1[i]) + 3 * t * t * (p3[i] - p2[i]); return { x: pos(0), y: pos(1), a: Math.atan2(d(1), d(0)) }; }; // The stem: the curve as a band tapering from w0 to w1. const band = (curve, w0, w1) => { const [left, right] = [[], []]; for (let i = 0; i <= 48; i++) { const p = bez(curve, i / 48); const w = (w0 + (w1 - w0) * (i / 48)) / 2; left.push([p.x - Math.sin(p.a) * w, p.y + Math.cos(p.a) * w]); right.unshift([p.x + Math.sin(p.a) * w, p.y - Math.cos(p.a) * w]); } return ring([...left, ...right]); }; // A bay leaf from its base (x, y) along angle a: narrow at the stalk, widest a third of the // way up, drawn out to a point, with its midrib bowed by `bend`. Returns the blade, the midrib // and four pairs of side veins. function bayLeaf(x, y, a, len, wide, bend) { const [c, s] = [Math.cos(a), Math.sin(a)]; const to = (u, v) => [x + u * c - v * s, y + u * s + v * c]; const mid = (t) => bend * len * Math.sin(Math.PI * t); const half = (t) => wide * Math.sin(Math.PI * t ** 0.72) ** 1.1; const edge = (sign) => Array.from({ length: 33 }, (_, i) => { const t = i / 32; return to(len * t, mid(t) + sign * half(t) * (1 + 0.035 * Math.sin(t * 23 + sign))); }); const veins = []; for (const t of [0.24, 0.4, 0.56, 0.7]) { for (const sign of [1, -1]) { veins.push(line([to(len * t, mid(t)), to(len * (t + 0.13), mid(t + 0.13) + sign * half(t + 0.13) * 0.72)])); } } return { blade: ring([...edge(1), ...edge(-1).reverse()]), rib: line(Array.from({ length: 17 }, (_, i) => to(len * i / 18, mid(i / 18)))), veins: veins.join('') }; } function laurel(w, h) { const stem = [[w + 40, -60], [w * 0.8, h * 0.12], [w * 0.62, h * 0.62], [w * 0.14, h * 0.7]]; const back = []; const front = []; const berries = []; const N = 15; for (let i = 0; i < N; i++) { const t = 0.03 + (i / (N - 1)) * 0.9; const p = bez(stem, t); const side = i % 2 ? 1 : -1; const len = (300 - 150 * t) * (0.88 + rand() * 0.24); const turned = i % 4 === 2; // seen edge-on, its paler underside up const a = p.a + side * (0.42 + rand() * 0.5); const stalk = [p.x + Math.cos(a) * 14, p.y + Math.sin(a) * 14]; const blade = bayLeaf(stalk[0], stalk[1], a, len, len * (turned ? 0.12 : 0.2), side * (0.04 + rand() * 0.05)); (turned ? back : front).push({ ...blade, stalk: line([[p.x, p.y], stalk]) }); if (i % 4 === 1 && t < 0.8) { // a small umbel of berries in the leaf's axil const b = p.a - side * 0.9; const hub = [p.x + Math.cos(b) * 26, p.y + Math.sin(b) * 26]; for (let k = 0; k < 4; k++) { const ba = b + (k - 1.5) * 0.42; const r = 40 + rand() * 12; berries.push({ stalk: line([[p.x, p.y], hub, [hub[0] + Math.cos(ba) * r * 0.6, hub[1] + Math.sin(ba) * r * 0.6]]), x: hub[0] + Math.cos(ba) * r, y: hub[1] + Math.sin(ba) * r, a: ba }); } } } const [end, bud] = [bez(stem, 1), bez(stem, 0.97)]; // the shoot ends in two young leaves front.push({ ...bayLeaf(end.x, end.y, end.a - 0.08, 120, 21, 0.05), stalk: '' }, { ...bayLeaf(bud.x, bud.y, bud.a + 0.55, 72, 13, -0.06), stalk: '' }); const [wood, pale, vein] = [mix('laurel', 'ink', 0.35), mix('leaf', 'paper', 0.25), mix('laurel', 'paper', 0.28)]; const out = []; for (const b of back) { out.push(stroke(b.stalk, wood, 5), fill(b.blade, palette.leaf), stroke(b.rib, pale, 3)); } out.push(fill(band(stem, 17, 6), wood)); for (const b of berries) { out.push(stroke(b.stalk, wood, 3.5), `<ellipse cx="${f1(b.x)}" cy="${f1(b.y)}" rx="21" ` + `ry="16.5" transform="rotate(${f1(b.a * 180 / Math.PI)} ${f1(b.x)} ${f1(b.y)})" ` + `fill="${palette.berry}"/>`); } for (const b of front) { out.push(stroke(b.stalk, wood, 5), fill(b.blade, palette.laurel), stroke(b.rib, vein, 3.2), stroke(b.veins, vein, 1.6)); } return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` + `viewBox="0 0 ${w} ${h}">${out.join('')}</svg>`; } await loadSvg('laurel.svg', laurel(1040, 740)); // tenths of a millimetre: 104 × 74 mm // #endregion const resources = [{ id: 'laurel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'laurel.svg', width: 1040, height: 740 }, altText: 'A sprig of bay laurel with a cluster of unripe berries, entering from the corner.' }]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded before the first build (gotcha: fonts-first). Linden Hill has no bold, Imbue no italic. const FONTS = { 'Linden Hill': ['400', '400i'], Imbue: ['300'], 'Libre Franklin': ['400', '500'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + notes); const source = `${numberVerse(markdown)}\n\n${notes}`; const doc = await buildWithFonts(() => buildDocument({ markdown: source, resources }, config()), source); showPages(doc, { title: 'Lycidas · with line numbers and notes' });
工具包 · 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上的食谱文件夹 ↗

变化

#每隔十行编号

长诗常按十行编号;6 mm的栏不需要改动。

-const EVERY = 5;
+const EVERY = 10;

#行号一律排在诗行右侧

把侧栏固定在右边后,每一页的行号都在诗行右侧,在左页上就靠近订口。

-  sideColumnSide: 'outer', // 右页在诗行右侧,左页在诗行左侧
+  sideColumnSide: 'right', // 每一页都在诗行右侧

常见问题

易错点

侧栏框与其围栏之后的块齐平开始

在postext 1.4.1中,span: 'side'的框在侧栏中的位置,是正文排到其围栏处时的高度,落在下一个网格行上,并排在已在那里的框之下。把旁注围在它所解释的段落之前:围在段落之后,旁注就会从下一段旁边开始。会超出栏底的框会向上滑,在上方框允许的范围内,直到底边落在栏底;仍然放不下的,就等到下一页的侧栏。 旁注 →

易错点

齐左文字从不断词

断词只用于两端对齐的文字;左对齐不齐行的文字在词间断行,所以窄栏的齐左文字行尾会很参差。把这段改为两端对齐,或加大行长。 断词与文档语言 →

易错点

齐左文字从不检查孤字

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

易错点

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

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

易错点

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

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

易错点

章首页的图片从不计入它预留的高度

在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →

易错点

标题样式会继承其级别的分页设置

headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →

易错点

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

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

易错点

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

postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

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

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

易错点

配置按对象身份缓存:每次新建一个对象

引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →

易错点

排版前加载所有字体

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

  • 带编号的行如果转行,行号会排在转行旁边,因为侧框的围栏放在整个段落之后。80 mm时,Lycidas没有哪一行会转行;如果缩短行长,就要检查带编号的行。
  • :::space在页首会被丢弃,所以第14行与第15行之间的诗节分隔正好落在第1页转第2页处,看不出来。如果你的版本必须显示每个诗节分隔,可以改为让每个诗节的第一行缩进,1645年版正是这样做的。
  • 关于侧框的卡片建议把注解的围栏放在其段落之前,让它与段落第一行平齐。行号则不同,它的围栏放在所在行之后,再用负内边距抬回去:如果放在之前,开启新一页的那一行,其行号会滑到上一页最后一行旁边(见第1步)。

致谢

文本
  • Lycidas, with its headnote, in the text of Poems of Mr. John Milton (1645), pages 57–65, from the proofread Wikisource transcription of the 1927 facsimile; checked against H. C. Beeching’s Oxford text (Project Gutenberg eBook 1745) · John Milton · 公有领域
  • The note on the text, the notes and the colophon · Ignacio Ferro · CC BY 4.0
图片
  • The sprig of bay laurel on the first page, drawn in code in the page’s greens · Ignacio Ferro · CC BY 4.0
字体
Linden Hill (SIL OFL 1.1) · Imbue (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)
沙盒