跳到主要内容
食谱编号32

排版食谱 · 第6章 · 框与注释

带页边注释的经典注释本

把《爱丽丝》中疯狂的茶会排成注释本:绿色和红色的边注放在外侧页边,紧挨所解释的文字,正文中用同色字母引出。

本页内容
输出
Canvas
难度
高级
Postext
已用Postext 1.6.0测试
需要≥ 1.4.1
许可证
更新于2026年9月28日
代码MIT · 文本CC BY 4.0

页码82–83 · 第2–3页,共3页

  • 英文样例:尚无中文版本
  • 成品尺寸156 × 234 mm
  • 一栏半, 栏间距4.5 mm
  • Unna 9.5/14
  • Cormorant SC
  • Rozha One
  • 3页
  • 难度
  • Postext 1.6.0
  • 排版用时44 ms
  • 219行代码

成品一览

《爱丽丝漫游奇境》(Alice’s Adventures in Wonderland)第七章疯狂的茶会的开头,排成注释本的三页,开本为156 × 234 mm的普通图书。卡罗尔的原文用Unna排成一栏两端对齐,每行约66个字符。切口一侧33 mm宽的边注栏放编者的八条边注,每条都紧挨带有其字母的段落,所以在右页它们排在右边,在左页排在左边。关于词语和笑话的边注用绿色,关于历史的用红色,正文中的每个字母也取其边注的颜色。本章开头是一幅俯视茶桌的插图,接着是章节编号、用Rozha One排的标题,以及以绿色首字下沉开头的斜体题记;关于本版本的说明放在题记旁的页边。

这道食谱解答

  • 怎样把边注或旁注排在它所解释的段落旁边?
  • 脚注怎么做?
  • 怎样做一栏半版式,一个宽的正文栏加一个窄的侧栏?
  • 怎样给章首页加作者行、提要或带首字下沉的导语?

简短回答

script.js · 第38–63行在完整代码中
const layout = {
  layoutType: 'oneAndHalf', // one text column and a narrower side column
  sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel
  sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures
  sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page
  gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm
};
// A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown.
// span="side" moves it out of the flow into the channel, level with the block after
// the fence, so the fence goes just before the paragraph that carries its letter.
// Glosses never float. One that meets the gloss above stacks under it. One that would
// run past the column's foot slides up, as far as the gloss above allows, until its
// foot sits on the foot; if it still does not fit, it waits for the next page
// (gotcha: side-box-starts-at-fence).
const gloss = (id, hue) => ({ id,
  backgroundEnabled: false, // one device: a hairline over the gloss, in its colour
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) },
  padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) },
  // Cormorant SC draws lower case as small capitals: the title needs no textTransform.
  titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5),
    letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) },
  // Face and colours come from bodyText. A box body also inherits its 4 mm first-line
  // indent, which a gloss sets back to 0.
  body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left',
    firstLineIndent: pt(0) } });
const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history

用料

类型
Unna, Rozha One, Cormorant SC(SIL OFL 1.1)
素材
  • The tea-table seen from above, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)

做法

#1 · 把切口一侧留给边注

代码见上面的简短回答。在一栏半版式中,sideColumnPercent: 26从页边距之间的127 mm里分给侧栏33 mm,扣除4.5 mm栏间距后正文还有89.5 mm。sideColumnRole: 'floats'让卡罗尔的正文不进入侧栏,侧栏只放用span: 'side'指定的内容。脚注要等到栏底、排在一条线下;而在页边,每条边注与它所解释的那一行齐平,也不占用正文栏的行。两种边注样式只在细线和标题的颜色上不同,读者不用读内容,就能分辨是词语注还是历史注。

#2 · 镜像页面,让边注栏跟着切口走

script.js · 第77–88行在完整代码中
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [156, 234]; // mm
const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text
const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  backgroundColor: col('paper'),
  // left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves
  // the channel with them: right on a recto, left on a verso.
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
    mirror: true } };
const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm
const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure
const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value;

设了mirror: true,页边距在每个左页上左右互换,sideColumnSide: 'outer'让边注栏随之移动,所以边注在第81页和第83页排在右边,在第82页排在左边。上下页边距各22 mm,留出190 mm,可容纳38行14 pt,每条边注都从同一网格的某一行开始。

#3 · 在所解释的段落之前为每条边注写围栏

script.js · 第92–107行在完整代码中
const bodyText = { // justified and hyphenated (en-us) by default
  // Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in
  // an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left
  // it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'.
  fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
  // Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt
  // dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69.
  minWordSpacing: 0.75 };
const paragraphStyles = [
  // The chapter's first paragraph, set flush. indentAfterHeading: false would do it
  // without glosses, but the gloss fences between the heading and this paragraph
  // count as the block after the heading, so the default stays and the paragraph takes
  // this style (gotcha: side-box-after-heading).
  { id: 'opening', firstLineIndent: pt(0) },
];

在:::callout围栏上加span="side"的框会离开文本流,与围栏之后的块齐平,排在边注栏里;所以每条边注的围栏都紧写在带有其字母的段落之前,如下所示。边注a和b应位于第一段旁,所以它们的围栏写在标题和该段之间。在1.4.1中,这两个框于是算作标题之后的块,indentAfterHeading: false管不到那一段,所以配置让这个选项保持默认值,改用opening样式让该段顶格。正文按栏宽调整了字号:9.5 pt时,以“Have some wine,”开头的那段正好排成一行89.5 mm;而10.4 pt时,行长从77 mm到93 mm都会留下“ing tone.”或“tone.”这样的短末行。Unna的词间空格很窄,只有0.22 em;minWordSpacing取默认值0.6时,第83页有一行的空格被压到这个宽度的0.69;minWordSpacing: 0.75排除了这一行。

:::callout{type="context" span="side" title="c · Hair"}
Tenniel drew Alice with long, loose hair. …
:::
 
“Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. …

#4 · 字母印成其边注的颜色

script.js · 第67–73行在完整代码中
// A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}:
// the Markdown has no mark for coloured text, and a chip style sets a colour. With no
// fill, outline, padding or gap the chip adds no width, so the lines break as they would
// with a plain ^a^.
const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), gap: pt(0), color: col(hue), bold: true });
const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')];

Markdown没有给文字上色的标记,但行内标签样式可以设置文字颜色,所以每个字母都是一个行内标签,其样式与所属边注的类型同名。:chip[^c^]{style="context"}印出一个红色的c,与边注c的细线和标题同为红色。没有填充、轮廓、内边距和间隙时,行内标签不增加宽度,每一行的断行位置都与用普通^c^时相同。

#5 · 在茶桌下开始本章

script.js · 第111–167行在完整代码中
const HEADPIECE = 72; // mm: the depth of the drawing at the head of the page
const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading
const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One
// The initial's size: its capital runs from the first line's cap height down to the
// last baseline it spans. The default size is as tall as both line boxes, so its top
// rises above the first line's capitals.
const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size)
  / CAPS.initial);
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width } });
// The opener reserves the height of its lowest text, rounded up to the grid, and the
// heading's default bottom margin adds one blank line: with a four-line headnote the
// text starts on line 23. The drawing reserves nothing
// (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang
// below it and carry the reserve past it.
const opener = { enabled: true, slot: { elements: [
  // An image element draws a resource without number or caption. It hangs from the
  // trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page.
  { kind: 'image', id: 'headpiece', resourceId: 'tea-table',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(TRIM_W) } } },
  // The numeral comes from the heading line,
  // # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so
  // {chapterNumber} would print 1 (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase',
    color: col('jam'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the drawing's foot
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY,
    fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left',
    // A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
    overflow: 'wrap',
    placement: below('kicker', 2, 'fill') },
  // Drop caps exist only in design text, which is set ragged
  // (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged;
  // Carroll's text opens in the flow.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT,
    italic: true, fontSize: pt(HEADNOTE.size),
    // A design text's lineHeight multiplies its size
    // (gotcha: design-lineheight-multiple).
    lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'),
    align: 'left', overflow: 'wrap', // a drop cap needs wrapping text
    dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'),
      gap: mm(1.5) },
    placement: below('title', 5, mm(TEXT_W)) },
  // The note on this edition stands in the channel, level with the headnote's top.
  { kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'),
    align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' },
      offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } },
  { kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT,
    fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left',
    overflow: 'wrap',
    placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) },
      size: { width: mm(SIDE_W) } } },
] } };

标题的设计槽跨越整页,插图才能伸到裁切线。图像元素不占用高度,所以眉题、标题和题记挂在插图下方,由它们决定预留高度:预留区到其中最低者为止,向上取整到网格;标题默认的下外边距再加一个空行。题记四行时,正文从第23行开始,多一行就会移到第24行。章节编号、题记和本版本说明来自标题属性{num="VII" headnote="…" source="…"}。编号从这里取,是因为节选本身是一个独立文档,{chapterNumber}会印出1。设计中的文字从不两端对齐,所以首字下沉用在编者的题记上,题记用斜体齐左排;卡罗尔的正文在下方文本流中两端对齐开始。dropSize()计算首字大小,使其顶端与题记第一行大写字母的顶端对齐。

#6 · 按解释对象给边注上色

script.js · 第16–32行在完整代码中
const palette = {
  ink: '#1f1b1a', // text: a warm near-black
  lawn: '#1e6f6b', // glosses on words and jokes, the drop cap; the lawn in the headpiece
  jam: '#b23a48', // glosses on history, the kicker; the headpiece's teapot and arm-chair
  biscuit: '#cdbfae', // the headpiece's chairs, saucers, bread and butter, March Hare
  muted: '#6e645b', // running heads, the note on this edition, the Dormouse
  paper: '#f8f3e6', // the page, and the tablecloth
};
// col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints
// design elements from the hex alone (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  // The engine's defaults link to 'main-color': aimed at the green, nothing prints blue.
  entry('main-color', palette.lawn, 'lawn (defaults)'),
];

草坪绿标记关于词语、俗语和笑话的边注,果酱红标记关于人物、日期和文本历史的边注。插图的颜色也取自同一个palette对象,所以改一下jam的值,关于历史的边注、它们的字母、眉题、茶壶和扶手椅就一次全部换色。每个关联颜色还带着自己的hex值,因为1.4.1用hex绘制章首页和书眉(见“常见问题”)。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 032 · Annotated classic with margin glosses ════════════
// https://postext.dev/en/cookbook/annotated-classic-glosses
// Code: MIT · Text: Lewis Carroll (public domain), glosses (CC BY 4.0) · Headpiece: in code
// Fonts: Unna, Rozha One, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The mad tea-party as an annotated edition: the text keeps to one column, and its glosses stand
// in the outer margin beside the lines they explain, changing sides with the spread.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'annotated-classic-glosses';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a lawn green and a jam red on cream paper
const palette = {
  ink: '#1f1b1a', // text: a warm near-black
  lawn: '#1e6f6b', // glosses on words and jokes, the drop cap; the lawn in the headpiece
  jam: '#b23a48', // glosses on history, the kicker; the headpiece's teapot and arm-chair
  biscuit: '#cdbfae', // the headpiece's chairs, saucers, bread and butter, March Hare
  muted: '#6e645b', // running heads, the note on this edition, the Dormouse
  paper: '#f8f3e6', // the page, and the tablecloth
};
// col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints
// design elements from the hex alone (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  // The engine's defaults link to 'main-color': aimed at the green, nothing prints blue.
  entry('main-color', palette.lawn, 'lawn (defaults)'),
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Unna', 'Rozha One', 'Cormorant SC'];
const LEAD = 14; // body leading in pt: the grid of every page

// #region answer: a float-only channel at the fore-edge, and two gloss styles for it
const layout = {
  layoutType: 'oneAndHalf', // one text column and a narrower side column
  sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel
  sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures
  sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page
  gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm
};
// A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown.
// span="side" moves it out of the flow into the channel, level with the block after
// the fence, so the fence goes just before the paragraph that carries its letter.
// Glosses never float. One that meets the gloss above stacks under it. One that would
// run past the column's foot slides up, as far as the gloss above allows, until its
// foot sits on the foot; if it still does not fit, it waits for the next page
// (gotcha: side-box-starts-at-fence).
const gloss = (id, hue) => ({ id,
  backgroundEnabled: false, // one device: a hairline over the gloss, in its colour
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) },
  padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) },
  // Cormorant SC draws lower case as small capitals: the title needs no textTransform.
  titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5),
    letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) },
  // Face and colours come from bodyText. A box body also inherits its 4 mm first-line
  // indent, which a gloss sets back to 0.
  body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left',
    firstLineIndent: pt(0) } });
const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history
// #endregion

// #region letters: the letter that calls each gloss, in the gloss's colour
// A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}:
// the Markdown has no mark for coloured text, and a chip style sets a colour. With no
// fill, outline, padding or gap the chip adds no width, so the lines break as they would
// with a plain ^a^.
const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), gap: pt(0), color: col(hue), bold: true });
const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')];
// #endregion

// #region page: a trade page with mirrored margins, so the channel is always at the fore-edge
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [156, 234]; // mm
const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text
const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  backgroundColor: col('paper'),
  // left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves
  // the channel with them: right on a recto, left on a verso.
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
    mirror: true } };
const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm
const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure
const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value;
// #endregion

// #region text: book texture and a flush opening paragraph
const bodyText = { // justified and hyphenated (en-us) by default
  // Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in
  // an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left
  // it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'.
  fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
  // Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt
  // dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69.
  minWordSpacing: 0.75 };
const paragraphStyles = [
  // The chapter's first paragraph, set flush. indentAfterHeading: false would do it
  // without glosses, but the gloss fences between the heading and this paragraph
  // count as the block after the heading, so the default stays and the paragraph takes
  // this style (gotcha: side-box-after-heading).
  { id: 'opening', firstLineIndent: pt(0) },
];
// #endregion

// #region opener: a headpiece, the chapter's numeral and title, a headnote with a drop cap
const HEADPIECE = 72; // mm: the depth of the drawing at the head of the page
const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading
const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One
// The initial's size: its capital runs from the first line's cap height down to the
// last baseline it spans. The default size is as tall as both line boxes, so its top
// rises above the first line's capitals.
const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size)
  / CAPS.initial);
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width } });
// The opener reserves the height of its lowest text, rounded up to the grid, and the
// heading's default bottom margin adds one blank line: with a four-line headnote the
// text starts on line 23. The drawing reserves nothing
// (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang
// below it and carry the reserve past it.
const opener = { enabled: true, slot: { elements: [
  // An image element draws a resource without number or caption. It hangs from the
  // trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page.
  { kind: 'image', id: 'headpiece', resourceId: 'tea-table',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(TRIM_W) } } },
  // The numeral comes from the heading line,
  // # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so
  // {chapterNumber} would print 1 (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase',
    color: col('jam'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the drawing's foot
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY,
    fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left',
    // A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
    overflow: 'wrap',
    placement: below('kicker', 2, 'fill') },
  // Drop caps exist only in design text, which is set ragged
  // (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged;
  // Carroll's text opens in the flow.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT,
    italic: true, fontSize: pt(HEADNOTE.size),
    // A design text's lineHeight multiplies its size
    // (gotcha: design-lineheight-multiple).
    lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'),
    align: 'left', overflow: 'wrap', // a drop cap needs wrapping text
    dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'),
      gap: mm(1.5) },
    placement: below('title', 5, mm(TEXT_W)) },
  // The note on this edition stands in the channel, level with the headnote's top.
  { kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'),
    align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' },
      offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } },
  { kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT,
    fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left',
    overflow: 'wrap',
    placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) },
      size: { width: mm(SIDE_W) } } },
] } };
// #endregion

// The running heads: the book on the verso, the chapter on the recto, folios at the fore-edge.
const HEAD = 14; // mm from the top trim to the heads' baseline
const INSET = 8; // mm from the folio's outer edge to the running head's
const DROP = 13; // mm from the bottom trim up to the foot of the drop folio's box
// In 1.4.1 a design text's first baseline sits 0.96 em below the top of its box: the default
// lineHeight, 1.2, times 0.8, where the baseline falls in the line box.
const BASELINE = 1.2 * 0.8;
const baseline = (size) => mm(HEAD - BASELINE * size * PT);
const head = (id, parity, content, x, extra = {}) => ({ kind: 'text', id, parity, content,
  pages: 'body', fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: baseline(extra.fontSize?.value ?? 8.5) } } });
const folio = { fontFamily: TEXT, fontWeight: 700, fontSize: pt(9), letterSpacing: pt(0),
  color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, folio),
  head('verso-title', 'even', '{title}', OUTER + INSET),
  head('recto-title', 'odd', '{chapterTitle}', -(OUTER + INSET)),
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, folio),
] };
// The opener, a recto, carries a drop folio at the foot of its channel instead.
const footer = { elements: [{ ...head('drop-folio', 'odd', '{pageNumber}', -OUTER, folio),
  pages: 'opener', placement: { anchor: { to: 'page', edge: 'bottom-right' },
    offset: { x: mm(-OUTER), y: mm(-DROP) } } }] };

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, page, layout, bodyText, paragraphStyles, calloutStyles, chipStyles, header,
  footer,
  headings: {
    fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), // Rozha One has one weight
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd' puts the opener on a recto; span 'page' lets its design cross the channel.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener },
    ],
  },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 121行 · content.en.mdtitle: "Alice’s Adventures in Wonderland" author: "Lewis Carroll" --- # A Mad Tea-Party {num="VII" headnote="The tea-party is not in the book Carroll wrote out by hand for Alice Liddell in 1864; he added it, with the Cheshire Cat, for the book of 1865. Letters in the text point to the glosses beside it: green for words and jokes, red for history." source="Text after Project Gutenberg eBook 11; notes CC BY 4.0. Set in Unna, Rozha One and Cormorant SC (SIL OFL)."} :::callout{type="note" span="side" title="a · The March Hare"} ‘Mad as a March hare’ is an old saying. Hares are shy, but in early spring they race about the fields and rear up on their hind legs to box. ::: :::callout{type="context" span="side" title="b · The Hatter"} ‘Mad as a hatter’ was a saying before Carroll used it. Hatters made felt from fur treated with mercury, and the fumes gave many of them tremors and fits of shyness and temper. Carroll never calls him the Mad Hatter. ::: :::paragraphs{style="opening"} There was a table set out under a tree in front of the house, and the March Hare:chip[^a^]{style="note"} and the Hatter:chip[^b^]{style="context"} were having tea at it: a Dormouse was sitting between them, fast asleep, and the other two were using it as a cushion, resting their elbows on it, and talking over its head. “Very uncomfortable for the Dormouse,” thought Alice; “only, as it’s asleep, I suppose it doesn’t mind.” ::: The table was a large one, but the three were all crowded together at one corner of it: “No room! No room!” they cried out when they saw Alice coming. “There’s *plenty* of room!” said Alice indignantly, and she sat down in a large arm-chair at one end of the table. “Have some wine,” the March Hare said in an encouraging tone. Alice looked all round the table, but there was nothing on it but tea. “I don’t see any wine,” she remarked. “There isn’t any,” said the March Hare. “Then it wasn’t very civil of you to offer it,” said Alice angrily. “It wasn’t very civil of you to sit down without being invited,” said the March Hare. “I didn’t know it was *your* table,” said Alice; “it’s laid for a great many more than three.” :::callout{type="context" span="side" title="c · Hair"} Tenniel drew Alice with long, loose hair. Alice Liddell, in Carroll’s photographs of her, wears hers short and dark, with a fringe. ::: “Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. He had been looking at Alice for some time with great curiosity, and this was his first speech. “You should learn not to make personal remarks,” Alice said with some severity; “it’s very rude.” :::callout{type="context" span="side" title="d · The riddle"} Carroll made up the riddle without an answer. So many readers asked for one that in a preface of 1896 he offered this: ‘Because it can produce a few notes, tho they are very flat; and it is nevar put with the wrong end in front!’ Later printings changed *nevar*, raven spelt backwards, to *never*, and the joke was lost. ::: The Hatter opened his eyes very wide on hearing this; but all he *said* was, “Why is a raven like a writing-desk?”:chip[^d^]{style="context"} “Come, we shall have some fun now!” thought Alice. “I’m glad they’ve begun asking riddles.—I believe I can guess that,” she added aloud. “Do you mean that you think you can find out the answer to it?” said the March Hare. “Exactly so,” said Alice. “Then you should say what you mean,” the March Hare went on. “I do,” Alice hastily replied; “at least—at least I mean what I say—that’s the same thing, you know.” :::callout{type="note" span="side" title="e · I see what I eat"} The Hatter has the logic right. Turn a statement round and you get its converse, which can be false when the statement is true. Carroll, as Charles Dodgson, taught mathematics at Christ Church, Oxford, and wrote two books on logic. ::: “Not the same thing a bit!” said the Hatter. “You might just as well say that ‘I see what I eat’ is the same thing as ‘I eat what I see’!”:chip[^e^]{style="note"} “You might just as well say,” added the March Hare, “that ‘I like what I get’ is the same thing as ‘I get what I like’!” “You might just as well say,” added the Dormouse, who seemed to be talking in his sleep, “that ‘I breathe when I sleep’ is the same thing as ‘I sleep when I breathe’!” “It *is* the same thing with you,” said the Hatter, and here the conversation dropped, and the party sat silent for a minute, while Alice thought over all she could remember about ravens and writing-desks, which wasn’t much. The Hatter was the first to break the silence. “What day of the month is it?” he said, turning to Alice: he had taken his watch out of his pocket, and was looking at it uneasily, shaking it every now and then, and holding it to his ear. :::callout{type="context" span="side" title="f · The fourth"} Of May: Alice Liddell was born on 4 May 1852. ::: Alice considered a little, and then said “The fourth.”:chip[^f^]{style="context"} “Two days wrong!” sighed the Hatter. “I told you butter wouldn’t suit the works!” he added looking angrily at the March Hare. :::callout{type="note" span="side" title="g · The best butter"} Grocers sold butter by grade, and ‘best’ fetched the highest price. The March Hare defends its quality, which was never the trouble. ::: “It was the *best* butter,”:chip[^g^]{style="note"} the March Hare meekly replied. “Yes, but some crumbs must have got in as well,” the Hatter grumbled: “you shouldn’t have put it in with the bread-knife.” The March Hare took the watch and looked at it gloomily: then he dipped it into his cup of tea, and looked at it again: but he could think of nothing better to say than his first remark, “It was the *best* butter, you know.” Alice had been looking over his shoulder with some curiosity. “What a funny watch!” she remarked. “It tells the day of the month, and doesn’t tell what o’clock it is!” “Why should it?” muttered the Hatter. “Does *your* watch tell you what year it is?” “Of course not,” Alice replied very readily: “but that’s because it stays the same year for such a long time together.” “Which is just the case with *mine*,” said the Hatter. Alice felt dreadfully puzzled. The Hatter’s remark seemed to have no sort of meaning in it, and yet it was certainly English. “I don’t quite understand you,” she said, as politely as she could. “The Dormouse is asleep again,” said the Hatter, and he poured a little hot tea upon its nose. :::callout{type="note" span="side" title="h · The Dormouse"} Hazel dormice sleep through the day and hibernate for half the year, from autumn to spring. The name is often traced to the French *dormir*, to sleep. ::: The Dormouse:chip[^h^]{style="note"} shook its head impatiently, and said, without opening its eyes, “Of course, of course; just what I was going to remark myself.” “Have you guessed the riddle yet?” the Hatter said, turning to Alice again. “No, I give it up,” Alice replied: “what’s the answer?” “I haven’t the slightest idea,” said the Hatter. “Nor I,” said the March Hare. Alice sighed wearily. “I think you might do something better with the time,” she said, “than waste it in asking riddles that have no answers.” “If you knew Time as well as I do,” said the Hatter, “you wouldn’t talk about wasting *it*. It’s *him*.” “I don’t know what you mean,” said Alice. “Of course you don’t!” the Hatter said, tossing his head contemptuously. “I dare say you never even spoke to Time!”
`; // content.<lang>.md, inlined by the Cookbook // #region art: the tea-table seen from above, drawn in the page's colours const mulberry32 = (seed) => () => { // a seeded generator: the same leaves on every run let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - k) + parseInt(b.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`; const P = palette; const LEAF = mix(P.lawn, P.ink, 0.35); const f1 = (n) => Math.round(n * 10) / 10; const circle = (x, y, r, fill, extra = '') => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(r)}" ` + `fill="${fill}"${extra}/>`; const ring = (x, y, r, stroke, w) => circle(x, y, r, 'none', ` stroke="${stroke}" stroke-width="${w}"`); const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const at = (x, y, turn, body) => `<g transform="translate(${f1(x)} ${f1(y)}) rotate(${f1(turn)})">` + `${body}</g>`; const rect = (x, y, w, h, r, fill, extra = '') => `<rect x="${x}" y="${y}" width="${w}" ` + `height="${h}" rx="${r}" fill="${fill}"${extra}/>`; // A cup on its saucer, from above: the handle points along `turn`. const cup = (x, y, turn, tea = true) => at(x, y, turn, circle(0, 0, 44, P.paper, ` stroke="${P.biscuit}" stroke-width="4"`) + ring(34, 0, 9, P.lawn, 6) + circle(0, 0, 27, P.paper, ` stroke="${P.lawn}" stroke-width="6"`) + (tea ? circle(0, 0, 19, mix(P.muted, P.biscuit, 0.35)) : '')); const teapot = (x, y) => at(x, y, 0, path('M54 -14C86 -20 98 -40 112 -52C106 -30 98 -6 60 16Z', P.jam) + ring(-68, 0, 24, P.jam, 12) + circle(0, 0, 62, P.jam) + circle(0, 0, 36, P.jam, ` stroke="${P.paper}" stroke-width="4"`) + circle(0, 0, 10, P.paper)); // Bread and butter: three slices on a plate. const plate = (x, y, turn) => at(x, y, turn, circle(0, 0, 50, P.paper, ` stroke="${P.lawn}" stroke-width="4"`) + [0, 120, 240].map((a) => at(0, 0, a, path('M-6 -8L-30 -34L22 -34Z', P.biscuit, ` stroke="${P.paper}" stroke-width="3"`))).join('')); // The butter on its dish, with the bread-knife that put the crumbs in the watch. const butter = (x, y) => at(x, y, -8, rect(-44, -28, 88, 56, 12, P.paper, ` stroke="${P.lawn}" stroke-width="4"`) + rect(-24, -16, 48, 32, 4, P.biscuit) + path('M-40 44L52 44L58 50L-40 50Z', P.muted)); // The Hatter's watch, on its chain. const watch = (x, y) => [0, 1, 2, 3, 4, 5, 6, 7].map((i) => circle(x - 52 - i * 15, y + 18 * Math.sin(i / 1.5), 5, 'none', ` stroke="${P.muted}" stroke-width="3"`)).join('') + circle(x - 40, y, 8, P.muted) + circle(x, y, 36, P.paper, ` stroke="${P.ink}" stroke-width="6"`) + path(`M${x} ${y}L${x} ${y - 25}M${x} ${y}L${x + 17} ${y + 8}`, 'none', ` stroke="${P.ink}" stroke-width="4" stroke-linecap="round"`) + circle(x, y, 4, P.ink); // The Hatter at the table's end: his silk hat from above, with a crescent of red band, a sheen // and the price ticket. const hatter = (x, y) => circle(x, y, 72, P.ink) + circle(x, y, 50, P.jam) + circle(x - 7, y - 9, 47, mix(P.ink, P.muted, 0.25)) + path(`M${x - 38} ${y - 24}A40 40 0 0 1 ${x + 6} ${y - 50}`, 'none', ` stroke="${P.paper}" stroke-opacity=".3" stroke-width="5" stroke-linecap="round"`) + at(x + 34, y + 30, 40, rect(-15, -11, 30, 22, 2, P.paper)); // The March Hare from behind, its ears laid back. const hare = (x, y) => [-16, 16].map((a) => at(x, y, a, path('M-18 20C-22 70 -12 116 0 122C12 116' + ' 22 70 18 20Z', P.biscuit) + path('M-8 40C-10 74 -5 100 0 104C5 100 10 74 8 40Z', P.jam, ' fill-opacity=".35"'))).join('') + circle(x, y, 40, P.biscuit); // The Dormouse asleep, curled in its tail. const dormouse = (x, y) => path(`M${x + 30} ${y + 8}C${x + 60} ${y + 40} ${x + 10} ${y + 64} ` + `${x - 24} ${y + 44}`, 'none', ` stroke="${P.muted}" stroke-width="7" stroke-linecap="round"`) + circle(x, y, 32, P.muted) + circle(x - 20, y - 24, 10, P.muted) + circle(x + 6, y - 30, 10, P.muted) + path(`M${x - 16} ${y - 8}q6 5 12 0`, 'none', ` stroke="${P.paper}" stroke-width="3" stroke-linecap="round"`); const chair = (x, y, turn) => at(x, y, turn, rect(-34, -30, 68, 60, 10, P.biscuit) + rect(-36, -44, 72, 12, 6, mix(P.biscuit, P.muted, 0.4))); // Alice's arm-chair at the far end, its back away from the table. const armchair = (x, y) => rect(x - 80, y - 80, 36, 160, 14, P.jam) + [-80, 52].map((dy) => rect(x - 70, y + dy, 140, 28, 12, P.jam)).join('') + rect(x - 46, y - 54, 112, 108, 16, mix(P.jam, P.paper, 0.25)); function teaTable() { const [W, H] = [TRIM_W * 10, HEADPIECE * 10]; // 0.1 mm units const [X0, X1, Y0, Y1] = [220, 1390, 200, 520]; // the tablecloth const rand = mulberry32(1865); const leaves = []; for (let i = 0; i < 70; i++) { // fallen leaves on the lawn, never on the table const [x, y] = [rand() * W, rand() * H]; if (x > X0 - 90 && x < X1 + 170 && y > Y0 - 90 && y < Y1 + 110) continue; const s = 0.6 + rand() * 0.7; leaves.push(at(x, y, rand() * 360, path(`M0 ${-26 * s}C${16 * s} ${-12 * s} ${16 * s} ` + `${12 * s} 0 ${26 * s}C${-16 * s} ${12 * s} ${-16 * s} ${-12 * s} 0 ${-26 * s}Z`, LEAF))); } const scallop = (x, y) => circle(x, y, 15, P.paper); const hem = []; // the cloth's scalloped edge for (let x = X0; x <= X1; x += 30) hem.push(scallop(x, Y0), scallop(x, Y1)); for (let y = Y0; y <= Y1; y += 30) hem.push(scallop(X0, y), scallop(X1, y)); const places = [340, 490, 640, 790, 940, 1090]; return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" ` + `viewBox="0 0 ${W} ${H}">${rect(0, 0, W, H, 0, P.lawn)}${leaves.join('')}` + places.map((x) => chair(x, Y0 - 58, 0) + chair(x + 40, Y1 + 58, 180)).join('') + armchair(150, (Y0 + Y1) / 2) + hare(1260, Y1 + 72) + rect(X0, Y0, X1 - X0, Y1 - Y0, 0, P.paper) + hem.join('') + rect(X0 + 28, Y0 + 28, X1 - X0 - 56, Y1 - Y0 - 56, 0, 'none', ` stroke="${P.jam}" stroke-width="3"`) + places.map((x, i) => cup(x, Y0 + 62, 200 + i * 23, i % 3 !== 1) + cup(x + 40, Y1 - 62, 20 - i * 31, i % 2 === 0)).join('') + cup(1215, 300, 150) + cup(1290, 420, 60) + cup(1195, 440, 250, false) // crowded at one end + plate(470, 360, 10) + butter(595, 350) + teapot(760, 360) + watch(1010, 360) + dormouse(1330, 300) + hatter(1470, 360) + '</svg>'; } // #endregion // The headpiece is a resource that only the opener's design draws: never cited, never placed. const resources = [{ id: 'tea-table', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'tea-table.svg', width: TRIM_W * 10, height: HEADPIECE * 10 }, altText: 'The tea-table seen from above: a long cloth laid with cups, a red teapot, butter ' + 'and a watch on its chain; a red arm-chair at one end and, at the other, the Hatter’s hat, ' + 'the Dormouse asleep and the March Hare’s ears.' }]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Unna: ['400', '400i', '700'], // 700: the folios and the gloss letters 'Rozha One': ['400'], 'Cormorant SC': ['600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('tea-table.svg', teaTable()); const continuation = { pageNumbering: { startAt: 81 } }; // chapter VII of a book: an odd folio const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Annotated classic with margin glosses' });
工具包 · 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上的食谱文件夹 ↗

变化

#边注每页都放在右边

如果这个版本是在屏幕上一页一页地读,就关掉镜像:'outer'于是表示每页的右边缘;书眉位置不变,因为它们锚定在裁切线上。

-    mirror: true } };
+    mirror: false } };

#把长注放到页脚

页边放不下的长注写成脚注:正文里写[^1],段落下写它的[^1]:定义。Postext把它排在正文栏底部,宽89.5 mm,上面有一条短线,边注栏仍归边注所用。食谱“脚注排在引用它的那一栏底部”讲的是这类注释的样式。

常见问题

易错点

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

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

易错点

标题后的侧栏框会让下一段缩进

在postext 1.4.1中,把span: 'side'的框围在标题和它的第一段之间,即使设了indentAfterHeading: false,这一段也会首行缩进:框离开了正文流,但它的块仍被算作标题之后的那个块。把框围在第一段之后。 旁注 →

易错点

{number}/{chapterNumber}打印H1编号;{numberRoman}只用于篇

{number}和{chapterNumber}打印标题格式化后的编号,但{numberRoman}、{numberDecimal}等其他数字变体只在篇页上填入。在numberingTemplate中格式化章号({1:I}),或者通过属性传入。 编号标题 →

易错点

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

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

易错点

设计文本从不两端对齐,所以首字下沉旁的导语参差不齐

在postext 1.4.1中,设计文本元素只能左对齐、居中或右对齐,并按词换行:没有两端对齐,hyphenate: true也只拆分长得一整行都放不下的词。因此排在dropCap旁的导语各行,在两端对齐的正文旁边参差不齐。导语只写首字旁边那几行,并手工调整让它们排满:用lines: 1(上升式首字)时导语只有一行,调整dropCap的间距就能让它齐边;段落的其余部分在Markdown里接着写。 章首页中的首字下沉 →

易错点

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

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

易错点

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

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

易错点

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

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

  • 边注按Markdown中的顺序一条接一条往下叠,所以一条长边注会把下一条推下去。在第81页,两个字母都落在第一段的第二行:边注a与该段齐平开始,边注b排在它下面,比自己的字母低五行。字母挨得近的地方,边注要写短。
  • 上面的“标题后的侧栏框会让下一段缩进”一条建议把框的围栏写在第一段之后。但写在那里的边注会从第二段旁开始(见“侧栏框与其围栏之后的块齐平开始”),所以这个版本把两个围栏都留在第一段之前,并用opening样式让该段顶格。

致谢

文本
  • Alice’s Adventures in Wonderland (1865), the opening of chapter VII, “A Mad Tea-Party” · Lewis Carroll · 公有领域
  • The headnote, the eight glosses and the note on this edition · Ignacio Ferro · CC BY 4.0
图片
  • The tea-table seen from above, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
字体
Unna (SIL OFL 1.1) · Rozha One (SIL OFL 1.1) · Cormorant SC (SIL OFL 1.1)
沙盒