跳到主要内容
食谱编号38

排版食谱 · 第9章 · 完整出版物

平装小说:下沉的章首页与右页起章

把《The Awakening》排成140 × 216 mm的平装本:一幅用代码画的封面、一页不带书眉的版权页,然后每章都从右页开始,正文下沉,上面是一个斜体章序号。

本页内容
  • 英文样例:尚无中文版本
  • 成品尺寸140 × 216 mm
  • 1栏
  • Crimson Pro 10.5/14
  • Cormorant Garamond
  • Cormorant SC
  • 8页
  • 难度
  • Postext 1.4.1
  • 排版用时22 ms
  • 179行代码

成品一览

一本140 × 216 mm平装书的八页,排的是Kate Chopin的The Awakening(1899)的前两章。封面用代码画成,是从Grand Isle上Lebrun家的小屋望出去的海湾:水橡树之间,地平线上有一艘小帆船,白色阳伞穿过春黄菊走上来。版权页排在它背面那一页的底部。每章都从右页开始,正文下沉到81 mm处,上面依次是48 pt的斜体章序号、一条18 mm长的铁锈红细线,以及用小型大写字母排的本章首句,按意思断成两行居中。第I章在第5页结束,末尾是一朵春黄菊章尾花,所以第6页留空,不带书眉和页码,第II章从第7页开始。书眉在左页印作者名,在右页用斜体印书名。

这道食谱解答

  • 怎样强制分页或分栏,并让每章都从右页开始?
  • 怎样给章首页加作者行、提要或带首字下沉的导语?
  • 怎样插入有意留白的页面,或让某一部分从新的跨页开始?
  • 怎样设置书眉:左页放书名,右页放章名,页码在外侧?
  • 怎样制作封面、简扉页、扉页和版权页?
  • 空行不起作用时,怎样在两个块之间加额外的垂直间距?
  • 怎样排不编号的插图:饰图、小花饰、标志?

简短回答

script.js · 第48–80行在完整代码中
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };

用料

类型
Crimson Pro, Cormorant Garamond, Cormorant SC(SIL OFL 1.1)
素材
  • The cover’s view of the gulf and the camomile tailpiece, drawn in code in the page’s palette (Ignacio Ferro, MIT)

做法

#1 · 章首页由标题及其属性生成

代码见上文的简短回答。Chopin的各章没有章名,所以每个标题只写章序号(# I),由{titleText}印出。{attr.lead}印出标题的lead属性,其中存放本章的第一句;标题下的Markdown段落从第二句开始(标题属性)。设计元素里的文本不能两端对齐,所以首句居中,每个属性里的\n按意思断行,分别断在cage之后和*bright;*之后。只有paragraphIndent大于零时,\n才会断行;设为0时,首句会把这两个字符原样印出。首句的框从版心顶端往下第九条网格线开始,minHeight设为12行,在它下面留出一个空行;不设minHeight,正文会紧贴在首句下面(通栏与高级设计)。marginBottom: pt(0)替换了这一级标题默认的半个em的外边距,否则正文会再往下挪一行。

breakBefore的parity设为'odd',把每一章都送到右页。配置里要重新写一遍,因为只要写了headings对象,这一级标题默认的分页就会丢掉(前置分页)。没有标题却需要强制换到右页时,在Markdown里写:::pagebreak{parity="odd"};在两栏及以上的版面里,:::columnbreak则把正文移到下一栏(:::pagebreak)。

#2 · 由整行构成的平装书页面

script.js · 第34–44行在完整代码中
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
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 * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};

下页边距等于216 mm的成品高度减去上页边距和35行14 pt之后剩下的部分,所以每个排满的页面都结束在同一行上,第3页和第4页就是这样。设了mirror: true之后,20 mm的内页边距在右页和左页上都落在书脊一侧(镜像页边距)。104.5 mm的行长可以容纳约69个10.5 pt的Crimson Pro字符。正文把词间距限定在正常值的0.7到1.9倍之间;如果用默认的最小值0.6,这几页上有一行会收紧到0.62。正文还设了maxRuntTracking: 0。用默认值时,以*nodding good-by to him.*结尾的那一段会用负字距排短一行,而Canvas不绘制负字距,结果印出来的词间距是挤在一起的。

#3 · 跳过章首页和空白页的书眉

script.js · 第84–104行在完整代码中
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };

parity: 'even'在左页用Cormorant SC小型大写字母印作者名,'odd'在右页用斜体印书名;页码位于版心的外侧边缘。pages: 'body'让这四个元素都不出现在章首页上,也不出现在第6页,也就是奇偶分页补出的那页空白左页。如果用pages: 'all',那一页会印出6和Kate Chopin。章首页改用页脚页码,这是一个页脚元素,由pages: 'opener'限定只出现在章首页(文本元素)。

#4 · 封面是一个不带书眉的分节

script.js · 第108–133行在完整代码中
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COVER_ART = { w: TRIM.width, h: 134 }; // mm: the drawing fills the width, 134 mm deep
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };

封面是一个标题:# The Awakening {style="cover"}。它的样式在上部134 mm里绘制插图,用50 pt排frontmatter里的书名,用小型大写字母排作者名。这个样式同样作用于该标题开启的分节,即第1页和第2页,直到第I章为止。样式里的header和footer都是空的,这样封面上不出现页脚页码,版权页上也不出现书眉。它的上页边距把版权页放在版心最后六条网格线上,使版权页的最后一行与第3页的最后一行平齐(标题样式)。即使只有一栏,这个样式也需要span: 'page'。留在栏内的设计会在栏的顶端被裁掉,这里栏顶在165 mm处;不设span: 'page',插图就印不出来,书名的上半部分也会被裁掉。

第2页:封面背面底部的版权页,没有书眉和页码。

#5 · 每章以一朵章尾花收尾

script.js · 第137–151行在完整代码中
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
const resources = [
  svg('cover', 'cover.svg', COVER_ART.w, COVER_ART.h, 'The gulf seen between the trunks of '
    + 'water-oaks, a lugger on the horizon; a white sunshade comes up from the beach through '
    + 'the camomile.'),
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];

ornament这种资源类型没有题注前缀,所以春黄菊花饰不带标签,在::resource{id="tailpiece-1"}所在的位置印出,宽度为行长的17%(资源类型)。行内嵌入的资源上方保留一条网格线,前面的:::space又在本章最后一行和花饰之间加了一条(:::space)。两朵章尾花用两个id绘制同一个文件fleuron.svg,因为一个资源只能放置一次。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══
// https://postext.dev/en/cookbook/trade-paperback-novel
// Code: MIT · Text: Kate Chopin, The Awakening, 1899 (PD, Gutenberg #160) · Art: drawn in code
// Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (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 sample document (this recipe is English only)
const RECIPE = 'trade-paperback-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231d18', // the text: a warm near-black
  rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece
  muted: '#6e655b', // running heads, folios on openers, the colophon
  paper: '#fbf7ef', // a cream book paper
  sea: '#2f5d6b', shallows: '#7fa6a3', sand: '#e8d9b8', camomile: '#d8b04a', // the cover
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, lists, callouts, captions) link to 'main-color', the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];
const TEXT = 'Crimson Pro'; // the text face
const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title
const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name
const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch
const line = (n) => pt(n * LEAD); // n grid lines

// #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
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 * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto, folios outside
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
// #endregion

// #region cover: a section of its own: art, title, author, no heads, a colophon on its verso
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COVER_ART = { w: TRIM.width, h: 134 }; // mm: the drawing fills the width, 134 mm deep
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
// #endregion

// #region tailpiece: an ornament type that prints no caption, where ::resource stands
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
const resources = [
  svg('cover', 'cover.svg', COVER_ART.w, COVER_ART.h, 'The gulf seen between the trunks of '
    + 'water-oaks, a lugger on the horizon; a white sunshade comes up from the beach through '
    + 'the camomile.'),
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  resourceTypes: [ornament],
  page,
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  // The designs paint every heading; the level's own face is the numeral's (500 italic), so the
  // kit has no unused face to load.
  headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] },
  headingStyles: [cover],
  paragraphStyles: [colophon],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 79行 · content.en.mdtitle: "The Awakening" author: "Kate Chopin" --- # The Awakening {style="cover"} :::paragraphs{style="colophon"} First published by Herbert S. Stone & Company, Chicago, in April 1899. Chapters I and II follow Project Gutenberg eBook #160, with Chopin’s spelling and punctuation. Set in Crimson Pro, Cormorant Garamond and Cormorant SC (SIL Open Font License). The cover, drawn in code, shows the gulf from the Lebrun cottages on Grand Isle. A Postext Cookbook edition. The text is in the public domain. ::: # I {lead="A green and yellow parrot, which hung in a cage\noutside the door, kept repeating over and over:"} “*Allez vous-en! Allez vous-en! Sapristi!* That’s all right!” He could speak a little Spanish, and also a language which nobody understood, unless it was the mocking-bird that hung on the other side of the door, whistling his fluty notes out upon the breeze with maddening persistence. Mr. Pontellier, unable to read his newspaper with any degree of comfort, arose with an expression and an exclamation of disgust. He walked down the gallery and across the narrow “bridges” which connected the Lebrun cottages one with the other. He had been seated before the door of the main house. The parrot and the mocking-bird were the property of Madame Lebrun, and they had the right to make all the noise they wished. Mr. Pontellier had the privilege of quitting their society when they ceased to be entertaining. He stopped before the door of his own cottage, which was the fourth one from the main building and next to the last. Seating himself in a wicker rocker which was there, he once more applied himself to the task of reading the newspaper. The day was Sunday; the paper was a day old. The Sunday papers had not yet reached Grand Isle. He was already acquainted with the market reports, and he glanced restlessly over the editorials and bits of news which he had not had time to read before quitting New Orleans the day before. Mr. Pontellier wore eye-glasses. He was a man of forty, of medium height and rather slender build; he stooped a little. His hair was brown and straight, parted on one side. His beard was neatly and closely trimmed. Once in a while he withdrew his glance from the newspaper and looked about him. There was more noise than ever over at the house. The main building was called “the house,” to distinguish it from the cottages. The chattering and whistling birds were still at it. Two young girls, the Farival twins, were playing a duet from “Zampa” upon the piano. Madame Lebrun was bustling in and out, giving orders in a high key to a yard-boy whenever she got inside the house, and directions in an equally high voice to a dining-room servant whenever she got outside. She was a fresh, pretty woman, clad always in white with elbow sleeves. Her starched skirts crinkled as she came and went. Farther down, before one of the cottages, a lady in black was walking demurely up and down, telling her beads. A good many persons of the *pension* had gone over to the *Chênière Caminada* in Beaudelet’s lugger to hear mass. Some young people were out under the water-oaks playing croquet. Mr. Pontellier’s two children were there—sturdy little fellows of four and five. A quadroon nurse followed them about with a faraway, meditative air. Mr. Pontellier finally lit a cigar and began to smoke, letting the paper drag idly from his hand. He fixed his gaze upon a white sunshade that was advancing at snail’s pace from the beach. He could see it plainly between the gaunt trunks of the water-oaks and across the stretch of yellow camomile. The gulf looked far away, melting hazily into the blue of the horizon. The sunshade continued to approach slowly. Beneath its pink-lined shelter were his wife, Mrs. Pontellier, and young Robert Lebrun. When they reached the cottage, the two seated themselves with some appearance of fatigue upon the upper step of the porch, facing each other, each leaning against a supporting post. “What folly! to bathe at such an hour in such heat!” exclaimed Mr. Pontellier. He himself had taken a plunge at daylight. That was why the morning seemed long to him. “You are burnt beyond recognition,” he added, looking at his wife as one looks at a valuable piece of personal property which has suffered some damage. She held up her hands, strong, shapely hands, and surveyed them critically, drawing up her fawn sleeves above the wrists. Looking at them reminded her of her rings, which she had given to her husband before leaving for the beach. She silently reached out to him, and he, understanding, took the rings from his vest pocket and dropped them into her open palm. She slipped them upon her fingers; then clasping her knees, she looked across at Robert and began to laugh. The rings sparkled upon her fingers. He sent back an answering smile. “What is it?” asked Pontellier, looking lazily and amused from one to the other. It was some utter nonsense; some adventure out there in the water, and they both tried to relate it at once. It did not seem half so amusing when told. They realized this, and so did Mr. Pontellier. He yawned and stretched himself. Then he got up, saying he had half a mind to go over to Klein’s hotel and play a game of billiards. “Come go along, Lebrun,” he proposed to Robert. But Robert admitted quite frankly that he preferred to stay where he was and talk to Mrs. Pontellier. “Well, send him about his business when he bores you, Edna,” instructed her husband as he prepared to leave. “Here, take the umbrella,” she exclaimed, holding it out to him. He accepted the sunshade, and lifting it over his head descended the steps and walked away. “Coming back to dinner?” his wife called after him. He halted a moment and shrugged his shoulders. He felt in his vest pocket; there was a ten-dollar bill there. He did not know; perhaps he would return for the early dinner and perhaps he would not. It all depended upon the company which he found over at Klein’s and the size of “the game.” He did not say this, but she understood it, and laughed, nodding good-by to him. Both children wanted to follow their father when they saw him starting out. He kissed them and promised to bring them back bonbons and peanuts. :::space ::resource{id="tailpiece-1"} # II {lead="Mrs. Pontellier’s eyes were quick and bright;\nthey were a yellowish brown, about the color of her hair."} She had a way of turning them swiftly upon an object and holding them there as if lost in some inward maze of contemplation or thought. Her eyebrows were a shade darker than her hair. They were thick and almost horizontal, emphasizing the depth of her eyes. She was rather handsome than beautiful. Her face was captivating by reason of a certain frankness of expression and a contradictory subtle play of features. Her manner was engaging. Robert rolled a cigarette. He smoked cigarettes because he could not afford cigars, he said. He had a cigar in his pocket which Mr. Pontellier had presented him with, and he was saving it for his after-dinner smoke. This seemed quite proper and natural on his part. In coloring he was not unlike his companion. A clean-shaved face made the resemblance more pronounced than it would otherwise have been. There rested no shadow of care upon his open countenance. His eyes gathered in and reflected the light and languor of the summer day. Mrs. Pontellier reached over for a palm-leaf fan that lay on the porch and began to fan herself, while Robert sent between his lips light puffs from his cigarette. They chatted incessantly: about the things around them; their amusing adventure out in the water—it had again assumed its entertaining aspect; about the wind, the trees, the people who had gone to the *Chênière;* about the children playing croquet under the oaks, and the Farival twins, who were now performing the overture to “The Poet and the Peasant.” Robert talked a good deal about himself. He was very young, and did not know any better. Mrs. Pontellier talked a little about herself for the same reason. Each was interested in what the other said. Robert spoke of his intention to go to Mexico in the autumn, where fortune awaited him. He was always intending to go to Mexico, but some way never got there. Meanwhile he held on to his modest position in a mercantile house in New Orleans, where an equal familiarity with English, French and Spanish gave him no small value as a clerk and correspondent. He was spending his summer vacation, as he always did, with his mother at Grand Isle. In former times, before Robert could remember, “the house” had been a summer luxury of the Lebruns. Now, flanked by its dozen or more cottages, which were always filled with exclusive visitors from the “*Quartier Français*,” it enabled Madame Lebrun to maintain the easy and comfortable existence which appeared to be her birthright. Mrs. Pontellier talked about her father’s Mississippi plantation and her girlhood home in the old Kentucky blue-grass country. She was an American woman, with a small infusion of French which seemed to have been lost in dilution. She read a letter from her sister, who was away in the East, and who had engaged herself to be married. Robert was interested, and wanted to know what manner of girls the sisters were, what the father was like, and how long the mother had been dead. When Mrs. Pontellier folded the letter it was time for her to dress for the early dinner. “I see Léonce isn’t coming back,” she said, with a glance in the direction whence her husband had disappeared. Robert supposed he was not, as there were a good many New Orleans club men over at Klein’s. When Mrs. Pontellier left him to enter her room, the young man descended the steps and strolled over toward the croquet players, where, during the half-hour before dinner, he amused himself with the little Pontellier children, who were very fond of him. :::space ::resource{id="tailpiece-2"}
`; // content.en.md, inlined by the Cookbook // #region art: the cover and the fleuron, drawn in millimetres in the page's palette function mulberry32(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); // A wavy band edge from x = 0 to W at height y, amplitude a: the top of a filled band. function edge(rnd, W, y, a, steps = 14) { let d = `M0 ${n2(y)}`; for (let i = 1; i <= steps; i++) { const x = (W * i) / steps; d += `Q${n2(x - W / steps / 2)} ${n2(y + (rnd() - 0.5) * 2 * a)} ` + `${n2(x)} ${n2(y + (rnd() - 0.5) * a)}`; } return d; } function drawCover(W, H) { // chapter I: the gulf, far and hazy, seen from the cottage porch const rnd = mulberry32(1899); const P = palette; const out = []; const shape = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`); const band = (y, a, color) => shape(`${edge(rnd, W, y, a)}L${W} ${H}L0 ${H}Z`, color); const bez = (a, b, c, d, t) => (1 - t) ** 3 * a + 3 * (1 - t) ** 2 * t * b + 3 * (1 - t) * t * t * c + t ** 3 * d; // The sky: pale blue overhead, warming into haze at the horizon; a white noon sun. const sky = mix(P.shallows, P.paper, 0.5); out.push(`<rect width="${W}" height="${H}" fill="${sky}"/>`); out.push(`<circle cx="47" cy="20" r="6" fill="${mix(P.sand, P.paper, 0.7)}"/>`); [[40, 0.7, 0.3], [52, 0.8, 0.55], [62, 0.6, 0.8]].forEach(([y, a, k]) => band(y, a, mix(sky, mix(P.sand, P.paper, 0.45), k))); // The gulf: melting into the haze at the horizon, deep further out, green over the shallows. const HORIZON = 70; out.push(`<rect y="${HORIZON}" width="${W}" height="${H - HORIZON}" ` + `fill="${mix(P.shallows, P.paper, 0.35)}"/>`); band(HORIZON + 4, 0.2, mix(P.sea, P.shallows, 0.35)); band(HORIZON + 11, 0.5, P.sea); band(HORIZON + 18, 0.8, mix(P.sea, P.shallows, 0.55)); for (let i = 0; i < 70; i++) { // glints on the water, longer in the foreground const y = HORIZON + 5 + rnd() * 17; const [x, len] = [rnd() * W, 1 + ((y - HORIZON) / 20) * 5 * (0.5 + rnd())]; out.push(`<path d="M${n2(x)} ${n2(y)}h${n2(len)}" stroke="${mix(P.shallows, P.paper, 0.5)}" ` + `stroke-width="${n2(0.25 + (y - HORIZON) / 60)}" stroke-linecap="round"/>`); } // Beaudelet's lugger on the horizon, on its way to the Chênière. shape(`M84 ${HORIZON + 0.6}h6.5l-1 1.3h-4.6z`, P.ink); shape(`M86.2 ${HORIZON + 0.4}L86.8 ${HORIZON - 7.2}L89.9 ${HORIZON - 5.4}` + `L89.2 ${HORIZON + 0.4}Z`, mix(P.rubric, P.sand, 0.3)); // The surf, the beach and the stretch of yellow camomile. band(HORIZON + 24, 0.7, P.paper); band(HORIZON + 25.3, 0.9, P.sand); band(HORIZON + 31, 1.2, mix(P.camomile, P.sand, 0.35)); band(HORIZON + 38, 1.6, mix(P.camomile, P.shallows, 0.2)); band(HORIZON + 50, 2, mix(P.camomile, P.sea, 0.25)); // The path up from the beach, widening as it comes near. const PATH_TOP = HORIZON + 30; const [left, right] = [[66, 68, 76, 70], [69.6, 74, 88, 96]]; // its edges, top to bottom shape(`M${left[0]} ${PATH_TOP}C${left[1]} 112 ${left[2]} 124 ${left[3]} ${H}H${right[3]}` + `C${right[2]} 124 ${right[1]} 112 ${right[0]} ${PATH_TOP}Z`, mix(P.sand, P.camomile, 0.2)); const onPath = (x, y) => { const t = (y - PATH_TOP) / (H - PATH_TOP); // the edges' y runs evenly return t >= 0 && x > bez(...left, t) - 1 && x < bez(...right, t) + 1; }; for (let i = 0; i < 560; i++) { // camomile heads, bigger and sparser towards the viewer const y = HORIZON + 32 + rnd() ** 0.8 * (H - HORIZON - 32); const x = rnd() * W; const r = 0.25 + ((y - HORIZON - 32) / (H - HORIZON)) * 1.4; if (onPath(x, y)) continue; out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r)}" fill="${P.paper}"/>` + `<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r * 0.45)}" fill="${P.camomile}"/>`); } // Edna and Robert under the white, pink-lined sunshade. const [sx, sy] = [70.5, HORIZON + 33]; shape(`M${sx - 1.3} ${sy}v4.2h1.1v-4.2z`, P.paper); shape(`M${sx + 0.4} ${sy}v4.2h1.1v-4.2z`, mix(P.ink, P.sea, 0.4)); shape(`M${sx - 3.2} ${sy - 0.2}Q${sx} ${sy - 3.6} ${sx + 3.2} ${sy - 0.2}Z`, P.paper); shape(`M${sx - 3.2} ${sy - 0.2}Q${sx} ${sy + 0.6} ${sx + 3.2} ${sy - 0.2}Z`, mix(P.rubric, P.paper, 0.55)); // The gaunt trunks of the water-oaks and their limbs: tapered curves, wide at the base. const bark = mix(P.ink, P.sea, 0.35); const bend = ([x0, y0], [cx, cy], [x1, y1], t) => [ (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1, (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1]; const limb = (p0, c, p1, w0, w1) => { const [l, r] = [[], []]; // the limb's two outlines for (let i = 0; i <= 12; i++) { const t = i / 12; const [x, y] = bend(p0, c, p1, t); const [dx, dy] = [(1 - t) * (c[0] - p0[0]) + t * (p1[0] - c[0]), (1 - t) * (c[1] - p0[1]) + t * (p1[1] - c[1])]; const k = (w0 + (w1 - w0) * t) / 2 / Math.hypot(dx, dy); l.push(`${n2(x - dy * k)} ${n2(y + dx * k)}`); r.unshift(`${n2(x + dy * k)} ${n2(y - dx * k)}`); } shape(`M${l.join('L')}L${r.join('L')}Z`, bark); }; // Each oak: a trunk (foot, bend, top; widths) and limbs that leave it at t along its length. [[[108, H + 1], [106, 70], [110, -1], 1.8, 1, []], // a third oak, further back [[9, H + 1], [4, 70], [17, -1], 4.6, 2.2, [[0.63, [22, 30], [40, 9], 2.4, 0.7], [0.78, [5, 18], [-2, 12], 1.6, 0.6]]], [[125, H + 1], [131, 70], [117, -1], 4.2, 2, [[0.66, [110, 30], [97, 10], 2.2, 0.7]]], ].forEach(([foot, c, top, w0, w1, limbs]) => { limb(foot, c, top, w0, w1); limbs.forEach(([t, lc, tip, l0, l1]) => limb(bend(foot, c, top, t), lc, tip, l0, l1)); }); const crown = (cx, cy, rx, ry, n) => { for (let i = 0; i < n; i++) { const a = rnd() * Math.PI * 2; const d = Math.sqrt(rnd()); const [x, y] = [cx + Math.cos(a) * rx * d, cy + Math.sin(a) * ry * d]; const shade = rnd() < 0.3 ? mix(P.sea, P.shallows, 0.4) : mix(P.sea, P.ink, 0.45); out.push(`<ellipse cx="${n2(x)}" cy="${n2(y)}" rx="${n2(1.8 + rnd() * 2.4)}" ` + `ry="${n2(1.2 + rnd() * 1.4)}" fill="${shade}"/>`); } }; crown(12, 3, 36, 13, 170); crown(128, 4, 32, 14, 150); // Spanish moss, in clumps hanging from the limbs. [[24, 20], [31, 14], [36, 11], [4, 15], [104, 16], [110, 21], [99, 12], [14, 13], [126, 13]] .forEach(([x, y]) => { for (let j = 0; j < 6; j++) { const [dx, len] = [(rnd() - 0.5) * 3.2, 4 + rnd() * 9]; out.push(`<path d="M${n2(x + dx)} ${n2(y)}c${n2(rnd() - 0.5)} ${n2(len / 3)} ` + `${n2(rnd() - 0.5)} ${n2((2 * len) / 3)} ${n2((rnd() - 0.5) * 1.5)} ${n2(len)}" ` + `stroke="${mix(P.shallows, P.sea, 0.2)}" stroke-width="0.5" fill="none" ` + 'stroke-linecap="round"/>'); } }); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}">${out.join('')}</svg>`; } function drawFleuron(color) { // a camomile head between two leafy sprigs const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" ` + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join(''); const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" ` + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>` + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>` + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>` + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" ' + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}` + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Crimson Pro': ['400', '400i'], // text, colophon, folios 'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head 'Cormorant SC': ['600'], // the leads, the author on the cover and the verso }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('cover.svg', drawCover(COVER_ART.w, COVER_ART.h)); await loadSvg('fleuron.svg', drawFleuron(palette.rubric)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Trade paperback: sunk openers and recto chapters' });
工具包 · 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上的食谱文件夹 ↗

变化

#让第II章紧接在下一页开始

parity设为'any'时,第II章从第6页开始,这是一页左页,全书在第7页结束;页脚页码以版心而不是页面为中心,所以在这页左页上它仍然位于正文中间的下方。

-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },

#在章内标出场景转换

把同一朵花饰放在两行:::space之间,就可以标出场景转换,因为indentAfterHeading是关闭的,花饰后面的段落顶格起排。放在*seemed long to him.*之后,花饰落在第4页底部;第二个空行在那里放不下,被丢掉了。这个分隔加了四行,把第I章的章尾花单独挤到第6页上,头上还带着书眉,所以加上分隔后要重新调整这一章的篇幅。

 const resources = [
+  svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),
That was why the morning seemed long to him.
 
:::space
 
::resource{id="break-1"}
 
:::space
 
“You are burnt beyond recognition,” he added, …

常见问题

易错点

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

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

易错点

第1页是右页:按物理页码规划页面

第1页是右页(奇数页),第2页是第一个左页(偶数页),所以要按物理页码规划跨页:偶数页上的章首页对着它后面的奇数页。 换页与换栏 →

易错点

属性中的\n只在paragraphIndent > 0时换行

在设计文本元素中,属性值里写的\n只有在paragraphIndent大于零或设置了首字下沉时才另起一行,否则文字留在同一行。把paragraphIndent设为一个极小值(0.01 pt),或每行用一个属性。 页面设计中的文字、线条和框 →

易错点

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

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

::resource{id="…"}只接受双引号

块级嵌入只有写成带双引号的::resource{id="…"}才能被识别;其他写法会作为一行可见文字留在正文中。 图就放在这里 →

易错点

frontmatter的每个值都加引号

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

易错点

排版前加载所有字体

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

沙盒检查 · parityCascade

奇偶连锁空白页

原因. 奇偶换页层层叠加,连续产生了两页以上的空白页。

解决. 检查always-odd换页和breakBefore的奇偶设置;章较短时,parity设为any通常可以避免这些空白页。 文档 →

  • 用'always-odd'时,每个分节后面都会先跟一页空白分隔页,再做奇偶补页,于是第I章会移到第5页,排在两页空白页之后,全书变成十页。
  • 这几页的篇幅是逐页调过的。外页边距从15.5 mm改成16 mm后,第7页会在第二个连字符处把after-din-ner断开,少排一行;第8页会以残片*spondent.*结束一段;第二朵章尾花则落到第九页。改动行长、字号或下沉深度后,要检查每一页的底部。
  • *Mr.和Mrs.用不换行空格(U+00A0)与人名连在一起。在1.4.1里,这在普通段落中有效,但在含斜体的段落里它会像普通空格一样断开。以seemed long to him.*结尾的那一段保留了普通空格,因为在那里把人名连起来,会使它的第一行词间距张开到正常值的2.2倍。

致谢

文本
图片
  • The cover’s view of the gulf and the camomile tailpiece, drawn in code in the page’s palette · Ignacio Ferro · MIT
字体
Crimson Pro (SIL OFL 1.1) · Cormorant Garamond (SIL OFL 1.1) · Cormorant SC (SIL OFL 1.1)
沙盒