跳到主要内容
食谱编号65

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

袖珍经典:接排的短章

把《Dom Casmurro》排成袖珍本:短章在栏内的章标题下接排,每处分页都不会孤零零留下一行正文或一个标题。

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

页码212–213 · 第2–3页,共7页

  • 英文样例:尚无中文版本
  • 成品尺寸110 × 178 mm
  • 1栏
  • Tinos 9.5/12.6
  • Abril Fatface
  • League Spartan
  • 7页
  • 难度
  • Postext 1.4.1
  • 排版用时17 ms
  • 171行代码

成品一览

从Machado de Assis 1899年的小说《Dom Casmurro》接近结尾处选取五页正文,按葡萄牙语原文排成110 × 178 mm的袖珍本,归入一套虚构的丛书Coleção Casuarina。封面由梅紫、奶油、梅紫三道色带构成,标题用Abril Fatface,另有丛书的藏红色圆徽;第213页对面是一幅插页,画的是Flamengo外海的大浪。正文从第CXVIII章末尾排到第CXXIII章末尾,没有一章另起一页。每一章都在上一章结尾下空两行开始(CXIX处空三行,以填满第213页),章标题由一个梅紫色的罗马数字章号、加大字距的大写标题和一道藏红色短线组成。第214页有两个章标题,第215页和第216页各有一个。除最后一页外,每页正文都止于第33行,也没有哪一页以单独一行开头。

这道食谱解答

  • 怎样避免段末孤行、段首孤行和只有一个词的末行(孤字),并让标题与正文不分开?
  • 怎样让各栏底部齐平,最后一页也排得平衡(垂直两端对齐)?
  • 怎样给标题编号(1、1.1、1.1.1),并给每一级设置不同样式?
  • 怎样设置书眉:左页放书名,右页放章名,页码在外侧?

简短回答

script.js · 第36–59行在完整代码中
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
    placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
      offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.

用料

类型
Tinos(Apache 2.0) · Abril Fatface, League Spartan(SIL OFL 1.1)
素材
  • The plate of the sea off Flamengo under the Sugarloaf, and the casuarina roundel, drawn in code in the book’s palette (Ignacio Ferro, MIT)

做法

#1 · 各章接排,章标题与正文不分离

script.js · 第36–59行在完整代码中
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
    placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
      offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.

章这一级关闭了分页,marginTop让每个章标题落在上一章最后一行下方两行处。它的版面设计没有span,所以章标题在栏内绘制,占一个三行高的块,下面的正文仍在基线网格上(分页前置、span与高级版面设计)。保持规则都用默认值。CXIX和CXXI下的四行段落各在页脚留两行、转两行到下一页;若设avoidOrphans: false,它们会拆成三行和一行,第214页和第215页就会以单独一行开头(段首孤行、段末孤行、孤字)。各栏齐底在CXIX上方加了第三行空白,使第213页止于第33行(各栏齐底)。

Page 213: chapter CXIX starts three lines under the end of CXVIII and keeps two lines of its text at the foot.

#2 · 每页33行,按葡萄牙语断词

script.js · 第63–73行在完整代码中
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does
  // not reach it in 1.4.1, and it would print blue
  firstLineIndent: mm(5), // every paragraph indented, the first after a head too
  maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it
// (gotcha: runt-tracking-unpainted); the fix keeps its word spacing
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales)

下页边距是16.3 mm,即178 mm的页高减去15 mm的上页边距和33行12.6 pt之后剩下的部分,所以每个满页都止于同一行。Tinos的字宽与Times New Roman相同,9.5 pt时在84 mm的行长上每行约排59个字符。locale: 'pt'用葡萄牙语的断词规则:at-tracção、af-fluencia、genealogica-mente(支持的语言)。每段都首行缩进,标题后的第一段也不例外,葡萄牙和巴西的版本通常如此,所以indentAfterHeading保留默认值。

#3 · 左页书眉印书名,右页印章名

script.js · 第77–90行在完整代码中
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };

{title}印出frontmatter中的标题,{chapterTitle}印出在本页或本页之前开始的最后一章,所以第217页印的是从第216页开始的CXXIII(文字元素)。第213页印的是在其页脚才开始的CXIX,尽管这一页的前25行还属于CXVIII:1.4.1没有表示“页面开头所在章”的占位符。SHIFT把书眉移到版心的中线上,与页面中线相差1 mm,因为内侧页边距比外侧宽2 mm。

#4 · 用标题画出封面和插页

script.js · 第94–137行在完整代码中
// span: 'page', in one column too, paints their art whole and keeps the \\ in the title
// (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha:
// cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the
// column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption
// only, since pictures do not count (gotcha: opener-image-no-reserve).
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 1.2 * PT, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0.8 * PT, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  { id: 'sea', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'sea.svg', width: TRIM.width, height: TRIM.height },
    altText: 'A heavy morning sea under the Sugarloaf, with two canoes rowing out.' },
];

两者都是numbered: false的标题样式,所以第一个编号的章仍是CXIX。span: 'page'让每个都成为独占一页的首页。没有它,封面的色带只画到版心的顶部和底部,书眉和页码1会印在封面上,标题也会排成一行。插页的题注来自它的标题:CAPÍTULO CXXIII是标题文字,斜体引文是它的quote属性(标题属性)。每个后面的:::pagebreak让正文避开图画,因为1.4.1不会为封面的页脚色带或插页的图画预留空间。

#5 · 告诉节选它在书中的位置

script.js · 第342行在完整代码中
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };

continuation.headings给出节选起始时的标题计数器。设为h1: 118时,第一个编号标题是第CXIX章;不设时,五章编号为I到V。章这一级的numberingTemplate: '{1:I}'用罗马数字排这些编号,版面设计用{number}印出。封面分页之后的:::numbering{startAt=212}把插页编为第212页,正文页编为第213到217页(编号)。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 065 · Pocket classic: short chapters that run on ═══════════
// https://postext.dev/en/cookbook/short-chapters-run-on
// Code: MIT · Text: Machado de Assis, Dom Casmurro, 1899 (PD, Gutenberg #55752) · Art: in code
// Fonts: Tinos (Apache 2.0), Abril Fatface, League Spartan (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the viewer's title; the sample is Portuguese
const RECIPE = 'short-chapters-run-on';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#24202a', // the text: a violet near-black
  paper: '#f6f1e6', // the pocket book's paper
  plum: '#4f2a49', // the series colour: cover bands, the plate, the chapter numerals
  saffron: '#d9a03c', // the second ink: rules and drawings, never text on paper (2.1:1)
  muted: '#6b616e', // the running heads and the colophon (5.2:1 on paper)
};
// Design slots read the hex, not the id, in 1.4.1 (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' } })),
  // The default bold and italic colours link to main-color, set here to the ink, not blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Tinos', 'Abril Fatface', 'League Spartan'];
const TRIM = { width: 110, height: 178 }; // mm: a pocket book
const [TOP, INNER, OUTER] = [15, 14, 12]; // mm; mirrored, so the inner margin is at the spine
const [BODY, LEAD, LINES] = [9.5, 12.6, 33]; // pt, pt, and the lines of a full page
const PT = 25.4 / 72; // mm per point
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const caps = (size, track) => ({ fontFamily: LABEL, fontWeight: 600, fontSize: pt(size),
  letterSpacing: pt(track), textTransform: 'uppercase' });

// #region answer: chapters that run on, each under a centred head drawn in the column
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
    placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
      offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
// #endregion

// #region text: a pocket page of 33 lines, in Portuguese
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does
  // not reach it in 1.4.1, and it would print blue
  firstLineIndent: mm(5), // every paragraph indented, the first after a head too
  maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it
// (gotcha: runt-tracking-unpainted); the fix keeps its word spacing
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales)
// #endregion

// #region heads: the book's title over the verso, the chapter over the recto, folios at the foot
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
// #endregion

// #region cover: the cover and the plate, heading styles that fill a page each
// span: 'page', in one column too, paints their art whole and keeps the \\ in the title
// (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha:
// cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the
// column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption
// only, since pictures do not count (gotcha: opener-image-no-reserve).
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 1.2 * PT, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0.8 * PT, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  { id: 'sea', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'sea.svg', width: TRIM.width, height: TRIM.height },
    altText: 'A heavy morning sea under the Sugarloaf, with two canoes rowing out.' },
];
// #endregion

// The colophon floats to the foot of the last page, beside the series roundel.
const colophon = { id: 'colophon', placement: 'bottom', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0),
  icon: { kind: 'resource', resourceId: 'roundel', size: mm(10) }, titleStyle: { gap: mm(3) },
  body: { fontFamily: TEXT, fontSize: pt(7.6), lineHeight: pt(LEAD * 0.8), color: col('muted'),
    italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  // The heading's own line is hidden under its design but still measured, in this face.
  headings: { fontFamily: LABEL, fontWeight: 600, levels: [chapters] },
  headingStyles: [cover, plate],
  calloutStyles: [colophon],
  header,
  footer,
});

// #region art: the series roundel and the plate, drawn in code in the book's two inks
let seed = 1871; // Mulberry32, seeded: 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 n = (v) => v.toFixed(2);
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k)
  + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" `
  + `height="${h}mm" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${n(width)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;

// The casuarina of Bento's garden (chapter II): a leaning trunk, and branches that arch out
// on alternate sides and let their needles hang.
function roundelSvg() {
  const { plum, saffron } = palette;
  const out = [`<circle cx="30" cy="30" r="29" fill="${saffron}"/>`,
    line('M30 30m-25.5 0a25.5 25.5 0 1 0 51 0a25.5 25.5 0 1 0 -51 0', plum, 0.9),
    line('M28.6 50Q30.6 33 30.2 10.5', plum, 1.6), line('M18.5 50.2H41.5', plum, 1.3)];
  for (let k = 0; k < 9; k++) {
    const side = k % 2 ? 1 : -1;
    const y0 = 12.5 + k * 3.7;
    const reach = (3 + k * 1.55) * (0.8 + rand() * 0.35);
    const x1 = 30.2 + side * reach;
    const y1 = y0 + 1.2 + rand() * 1.4;
    out.push(line(`M30.2 ${n(y0)}Q${n(30.2 + side * reach * 0.5)} ${n(y0 - 2.2)} ${n(x1)} ${n(y1)}`,
      plum, 0.95));
    const strands = 3 + Math.round(reach / 1.6);
    for (let j = 1; j <= strands; j++) { // needles hang from the arch
      const u = j / (strands + 0.5);
      const x = 30.2 + side * reach * u;
      const y = y0 + (y1 - y0) * u ** 2 - 2.2 * 2 * u * (1 - u) + 0.3;
      out.push(line(`M${n(x)} ${n(y)}q${n(side * 0.4)} ${n(2)} ${n(side * 0.1)} `
        + `${n(3 + rand() * 2.4)}`, plum, 0.7));
    }
  }
  return svg(60, 60, out.join(''));
}

// The plate: the Sugarloaf and Urca in the haze, the sun low beside them, and the swell in
// rows that deepen towards the reader; the nearest one is dark enough to carry the caption.
function seaSvg() {
  const { plum, saffron, paper } = palette;
  const [W, H, SKY, FOOT] = [TRIM.width, TRIM.height, 76, 136]; // mm: horizon, nearest swell
  const out = [];
  for (let i = 0; i < 6; i++) { // the sky in flat bands, warmer towards the horizon
    out.push(`<rect y="${n(i * 13)}" width="${W}" height="${n(SKY - i * 13)}" `
      + `fill="${mix(paper, saffron, 0.12 + i * 0.1)}"/>`);
  }
  out.push(`<circle cx="36" cy="${SKY - 8}" r="10" fill="${saffron}"/>`); // behind the hills
  const hills = `M-2 ${SKY} L6 ${SKY - 4} Q13 ${SKY - 9} 21 ${SKY - 5} L28 ${SKY - 3} `
    + `Q40 ${SKY - 8} 50 ${SKY - 4} Q57 ${SKY - 15} 64 ${SKY - 12} Q68 ${SKY - 11} 70 ${SKY - 7} `
    + `L73 ${SKY - 9} Q76 ${SKY - 41} 84 ${SKY - 40} Q92 ${SKY - 37} 94 ${SKY - 8} `
    + `L104 ${SKY - 3} L112 ${SKY} Z`; // Urca, then the Sugarloaf
  out.push(`<path d="${hills}" fill="${mix(plum, saffron, 0.3)}"/>`);
  out.push(`<rect y="${SKY}" width="${W}" height="${H - SKY}" fill="${plum}"/>`);
  for (let k = 0; k < 5; k++) { // the sun on the water, in broken strokes
    const half = 7 - k * 1.2;
    out.push(line(`M${n(36 - half + rand() * 2)} ${n(SKY + 1 + k * 1.6)}h${n(half * 1.6)}`,
      saffron, 0.8 - k * 0.1));
  }
  // Swell: each row is a filled wave front; later rows overlap the earlier ones.
  const rows = 14;
  for (let row = 0; row < rows; row++) {
    const t = row / (rows - 1);
    const base = SKY + 3 + (FOOT - SKY - 3) * t ** 1.35;
    const amp = 0.4 + t * 3.2;
    const length = 9 + t * 34;
    const phase = rand() * length;
    const pts = [];
    for (let x = -4; x <= W + 4; x += 1.5) {
      const y = base - amp * Math.sin(((x + phase) / length) * Math.PI * 2)
        - amp * 0.35 * Math.sin(((x + phase) / (length * 0.47)) * Math.PI * 2);
      pts.push(`${n(x)} ${n(y)}`);
    }
    const last = row === rows - 1; // the nearest swell, dark enough to carry the caption
    const tone = last ? mix(plum, '#000000', 0.25)
      : mix(plum, row % 2 ? '#000000' : paper, row % 2 ? 0.04 + t * 0.12 : 0.1 - t * 0.07);
    out.push(`<path d="M${pts.join(' L')} L${W + 4} ${H} L-4 ${H} Z" fill="${tone}"/>`);
    if (row % 2 === 0) { // broken foam along every other crest
      out.push(line(`M${pts.join(' L')}`, mix(plum, paper, 0.45 - t * 0.15), 0.25 + t * 0.3,
        ` stroke-dasharray="${n(3 + t * 9)} ${n(5 + t * 12)}" opacity="0.8"`));
    }
  }
  for (const [x, y, s] of [[22, SKY + 11, 0.8], [61, SKY + 19, 1.15]]) { // the canoes
    const dark = mix(plum, '#000000', 0.55);
    out.push(`<path d="M${n(x)} ${n(y)}q${n(5 * s)} ${n(2.2 * s)} ${n(10 * s)} 0`
      + `q${n(-5 * s)} ${n(0.8 * s)} ${n(-10 * s)} 0Z" fill="${dark}"/>`,
    `<circle cx="${n(x + 4.2 * s)}" cy="${n(y - 2.3 * s)}" r="${n(0.75 * s)}" fill="${dark}"/>`,
    line(`M${n(x + 4.2 * s)} ${n(y - 1.6 * s)}l${n(0.5 * s)} ${n(1.7 * s)}`, dark, 0.9 * s),
    line(`M${n(x + 1.5 * s)} ${n(y - 1.2 * s)}l${n(5.5 * s)} ${n(3.4 * s)}`, dark, 0.35 * s));
  }
  return svg(W, H, out.join(''));
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// Dom Casmurro in Portuguese, in the first edition's spelling: the cover, the plate, then
// pages 213 to 217 (:::numbering in the text), from the end of chapter CXVIII.
const markdown = String.raw`---
Markdown样例 · 63行 · content.en.mdtitle: "Dom Casmurro" author: "Machado de Assis" --- # Dom \\ Casmurro {style="cover"} :::pagebreak :::numbering{startAt=212} # Capítulo CXXIII {style="plate" quote="«…grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.»"} :::pagebreak O retrato de Escobar, que eu tinha alli, ao pé do de minha mãe, falou-me como se fosse a propria pessoa. Combati sinceramente os impulsos que trazia do Flamengo; rejeitei a figura da mulher do meu amigo, e chamei-me desleal. Demais, quem me affirmava que houvesse alguma intenção daquella especie no gesto da despedida e nos anteriores? Tudo podia ligar-se ao interesse da nossa viagem. Sancha e Capitú eram tão amigas que seria um prazer mais para ellas irem juntas. Quando houvesse alguma intenção sexual, quem me provaria que não era mais que uma sensação fulgurante, destinada a morrer com a noite e o somno? Ha remorsos que não nascem de outro peccado, nem tem maior duração. Agarrei-me a esta hypothese que se conciliava com a mão de Sancha, que eu sentia de memoria dentro da minha mão, quente e demorada, apertada e apertando… Sinceramente, eu achava-me mal entre um amigo e a attracção. A timidez póde ser que fosse outra causa daquella crise; não é só o ceu que dá as nossas virtudes, a timidez tambem, não contando o acaso, mas o acaso é um méro accidente; a melhor origem dellas é o ceu. Entretanto, como a timidez vem do ceu, que nos dá a compleição, a virtude, filha della é, genealogicamente, o mesmo sangue celestial. Assim reflectiria, se pudesse; mas a principio vaguei á tôa. Paixão não era nem inclinação. Capricho seria ou quê? Ao fim de vinte minutos era nada, inteiramente nada. O retrato de Escobar pareceu falar-me; vi-lhe a altitude franca e simples, sacudi a cabeça e fui deitar-me. # Não faça isso, querida A leitora, que é minha amiga e abriu este livro com o fim de descançar da cavatina de hontem para a valsa de hoje, quer fechal-o ás pressas, ao ver que beiramos um abysmo. Não faça isso, querida; eu mudo de rumo. # Os autos Na manhã seguinte accordei livre das abominações da vespera; chamei-lhes allucinações, tomei café, percorri os jornaes e fui estudar uns autos. Capitú e prima Justina sairam para a missa das nove, na Lapa. A figura de Sancha desappareceu inteiramente no meio das allegações da parte adversa, que eu ia lendo nos autos, allegações falsas, inadmissiveis, sem apoio na lei nem nas praxes. Vi que era facil ganhar a demanda; consultei Dalloz, Pereira e Souza… Uma só vez olhei para o retrato de Escobar. Era uma bella photographia tirada um anno antes. Estava de pé, sobrecasaca abotoada, a mão esquerda no dorso de uma cadeira, a direita mettida ao peito, o olhar ao longe para a esquerda do espectador. Tinha garbo e naturalidade. A moldura que lhe mandei pôr não encobria a dedicatoria, escripta embaixo, não nas costas do cartão: «Ao meu querido Bentinho o seu querido Escobar 20-4-70.» Estas palavras fortaleceram-me os pensamentos daquella manhã, e espancaram de todo as recordações da vespera. Naquelle tempo a minha vista era boa; eu podia lel-as do logar em que estava. Tornei aos autos. # A catastrophe No melhor delles, ouvi passos precipitados na escada, a campainha soou, soaram palmas, golpes na cancella, vozes, acudiram todos, acudi eu mesmo. Era um escravo da casa de Sancha que me chamava: —Para ir lá… sinhô nadando, sinhô morrendo. Não disse mais nada, ou eu não lhe ouvi o resto. Vesti-me, deixei recado a Capitú e corri ao Flamengo. Em caminho, fui adivinhando a verdade. Escobar metteu-se a nadar, como usava fazer, arriscou-se um pouco mais fóra que de costume, apesar do mar bravio, foi enrolado e morreu. As canoas que acudiram mal puderam trazer-lhe o cadaver. # O enterro A viuva… Poupo-vos as lagrimas da viuva, as minhas, as da outra gente. Sai de lá cerca de onze horas; Capitú e prima Justina esperavam-me, uma com o parecer abatido e estupido, outra enfastiada apenas. —Vão fazer companhia a pobre Sanchinha; eu vou cuidar do enterro. Assim fizemos. Quiz que o enterro fosse pomposo, e a affluencia dos amigos foi numerosa. Praia, ruas, praça da Gloria, tudo eram carros, muitos delles particulares. A casa, não sendo grande, não podiam lá caber todos; muitos estavam na praia, falando do desastre, apontando o logar em que Escobar fallecèra, ouvindo referir a chegada do morto. José Dias ouviu tambem falar dos negocios do finado, divergindo alguns na avaliação dos bens, mas havendo accordo em que o passivo devia ser pequeno. Elogiavam as qualidades de Escobar. Um ou outro discutia o recente gabinete Rio Branco; estavamos em Março de 1871. Nunca me esqueceu o mez nem o anno. Como eu houvesse resolvido falar no cemiterio, escrevi algumas linhas e mostrei-as em casa a José Dias, que as achou realmente dignas do morto e de mim. Pediu-me o papel, recitou lentamente o discurso, pesando as palavras, e confirmou a primeira opinião; no Flamengo espalhou a noticia. Alguns conhecidos vieram interrogar-me: —Então, vamos ouvil-o? —Quatro palavras. Poucas mais seriam. Tinha-as escripto com receio de que a emoção me impedisse de improvisar. No tilbury em que andei uma ou duas horas, não fizera mais que recordar o tempo do seminario, as relações de Escobar, as nossas sympathias, a nossa amizade, começada, continuada e nunca interrompida, até que um lance da fortuna fez separar para sempre duas creaturas que promettiam ficar por muito tempo unidas. De quando em quando enxugava os olhos. O cocheiro aventurou duas ou tres perguntas sobre a minha situação moral; não me arrancando nada, continuou o seu officio. Chegando a casa, deitei aquellas emoções ao papel; tal seria o discurso. # Olhos de ressaca Emfim, chegou a hora da encommendação e da partida. Sancha quiz despedir-se do marido, e o desespero daquelle lance consternou a todos. Muitos homens choravam tambem, as mulheres todas. Só Capitú, amparando a viuva, parecia vencer-se a si mesma. Consolava a outra, queria arrancal-a dalli. A confusão era geral. No meio della, Capitú olhou alguns instantes para o cadaver tão fixa, tão apaixonadamente fixa, que não admira lhe saltassem algumas lagrimas poucas e caladas… As minhas cessaram logo. Fiquei a ver as della; Capitú enxugou-as depressa, olhando a furto para a gente que estava na sala. Redobrou de caricias para a amiga, e quiz leval-a; mas o cadaver parece que a retinha tambem. Momento houve em que os olhos de Capitú fitaram o defuncto, quaes os da viuva, sem o pranto nem palavras desta, mas grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã. :::callout{type="colophon"} Do fim do capítulo CXVIII ao fim do CXXIII de *Dom Casmurro* (1899), com a ortografia da edição Garnier transcrita pelo Project Gutenberg (n.º 55752); corrigiu-se um erro de transcrição no capítulo CXXIII. Ilustração e marca da coleção desenhadas para esta edição. Composto em Tinos (Apache 2.0), Abril Fatface e League Spartan (SIL OFL). :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Tinos: ['400', '400i'], // text, colophon; italic: the plate's quote, the colophon's title 'Abril Fatface': ['400'], // chapter numerals and the cover title 'League Spartan': ['600'], // chapter titles, running heads, folios, the cover's capitals }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('roundel.svg', roundelSvg()); await loadSvg('sea.svg', seaSvg()); // #region excerpt: these pages continue a book: chapter CXVIII is under way, the next is CXIX const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // #endregion const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Dom Casmurro, a pocket edition', es: 'Dom Casmurro, edición de bolsillo' }) });
工具包 · 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上的食谱文件夹 ↗

变化

#每章另起一页

每章之前分页时,五页正文变成七页:第214页只有CXIX的标题和四行正文,而没有任何一章开始的第213页,会把插页的标题CAPÍTULO CXXIII当作书眉。

-  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
+  breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(2 * LEAD),
+  advancedDesign: chapterHead };

#右页书眉加上章号

{chapterNumber}印出与章名同一章的章号:CXIX · NÃO FAÇA ISSO, QUERIDA。

-  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT),
+  head('recto-chapter', '{chapterNumber} · {chapterTitle}', 'odd', SHIFT),

常见问题

易错点

avoidWidows管栏底,avoidOrphans管栏顶

Postext对两种孤行的命名自成一套:avoidWidows(widowMinLines、widowPenalty)防止段落的首行单独留在栏底,avoidOrphans(orphanMinLines、orphanPenalty)防止段落的末行单独出现在下一栏的栏顶。很多体例手册的叫法正好相反,所以要按作用的位置选择设置。两者默认开启,都以惩罚值的方式起作用:排版时会把每一项与遵守它所留下的空行放在一起权衡。 段末孤行、段首孤行与孤字 →

易错点

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

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

易错点

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

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

易错点

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

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

易错点

消除孤字时收紧的字距可能根本不绘制

在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →

易错点

整页封面之后加:::pagebreak

在多栏版面中,封面这类整页章首页之后,下一个块可能从同一页的第2栏开始,压在图上。在封面标题后紧跟一个:::pagebreak,就能结束这一页,而且不会多出空白页。 封面、扉页和版权页 →

易错点

比栏还高的章首页会失去全部预留空间

在postext 1.4.1中,当高级设计标题预留的高度(其minHeight,或其最低的设计元素)超过它所开启的栏时,标题只保留它自身标题行的高度,不给警告,正文就排到设计上面。整页插图需要一栏与页面等高:给它的标题样式设零外边距,再把minHeight设为页面高度。 设计过的章首页 →

易错点

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

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

易错点

留在栏内的章首页在版心顶部被裁切

在postext 1.4.1中,留在栏内的高级设计标题会在栏的上边缘被裁切:锚定在页面或出血上的框或图片可以画进左右页边距,却画不进上页边距,而且没有任何警告。给这样的标题设span: 'page',即使是单栏的书也一样:这样它的设计会作为页面的章首页区段完整地绘制出来。 设计过的章首页 →

易错点

:::numbering从下一页起生效

:::numbering从下一个开始的页面起改变计数,而不是当前页。把它紧跟在:::pagebreak之后(第一章之前用parity odd),新的编号才会从你想要的地方开始。 罗马数字页码的前置部分 →

易错点

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

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

易错点

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

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

易错点

frontmatter的每个值都加引号

YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →

易错点

排版前加载所有字体

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

  • 在1.4.1中把widowMinLines提高到3,CXIX和CXXI就能在页脚的标题下保留三行,而它们的四行段落会拆成三行和一行:无论orphanPenalty是多少,第214页和第215页都会以单独一行开头。
  • 如果CXVIII多出几行,CXIX的标题下就只剩一行的空间。这时keepWithNext会把标题带到第214页,第213页会短四五行;设keepWithNext: false时,标题会在第213页末尾孤零零地收尾,下面没有正文。
  • 插页是H1,所以{chapterTitle}会退回到它:插页之后还没有任何一章开始的正文页,比如CXIX移走之后的第213页,会把CAPÍTULO CXXIII印作书眉。
  • 关闭avoidRunts,全书最后一行就只剩manhã.,而不是nadador da manhã.。
  • 去掉封面后的:::pagebreak并且没有插页时,第CXVIII章会从封面顶部往下19.5 mm处、3 mm的标题行下方开始。去掉插页后的那个分页,题注上移到94 mm后,会有十一行正文印在海面上。
  • 这几页没有一行在附着代词的连字符处断开(Vesti-me、metteu-se)。若把CXXIII的第一个词Emfim设为斜体,1.4.1会让这一段走带行内标记文本的断行流程:一行止于vencer-,下一行以se a si mesma开头,没有按葡萄牙语拼写规则在行首重复连字符。
  • 这些分页只对这段文字、9.5/12.6 pt、84 mm行长成立。修改文字、字号或行长后,要重新检查每页页脚和每个标题上方的空白。

致谢

文本
  • Dom Casmurro (1899), from the end of chapter CXVIII to the end of chapter CXXIII, in the spelling of the Garnier edition, with one transcription slip corrected (homens, chapter CXXIII) · Machado de Assis · 公有领域
  • The colophon, and the series name Coleção Casuarina (invented for this recipe) · Postext Cookbook · 原创
图片
  • The plate of the sea off Flamengo under the Sugarloaf, and the casuarina roundel, drawn in code in the book’s palette · Ignacio Ferro · MIT
字体
Tinos (Apache 2.0) · Abril Fatface (SIL OFL 1.1) · League Spartan (SIL OFL 1.1)
沙盒