成品一览
一部袖珍版蒙田《随笔集》(Charles Cotton英译本)的第29至33页:第IV章全文和第V章的开头。左页书眉排书名,用加宽字距的大写字母;右页书眉排随笔标题,用斜体,并以省略号截短,因为第IV章的标题长达81个字符。每个页码都放在外侧页边距里的一个深褐色标签中,与版心边缘的一条0.5 pt细线相距3 mm。章首页不排书眉,只在页脚排页码。正文用Baskervville,排在暖白色的页面上;标题用Libre Caslon Display,页码和书名用Alegreya SC。每一个书眉都在配置中定义,因此引擎内置的书眉(8 pt的蓝色Open Sans)永远不会印出来。
这道食谱解答
- 怎样设置书眉:左页放书名,右页放章名,页码在外侧?
- 怎样在章首页和空白页上隐藏书眉,或者把空白左页涂成篇的颜色?
- 怎样让标题、粗体字和项目符号不再显示成蓝色?
简短回答
const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge
const HEAD = 12; // mm from the top edge of the page to the top of the tab
const CAP = t({ en: 60.5, es: 66.8 }); // mm: fitted so essay IV's head ends on a word
// (gotcha: ellipsis-mid-word); a short running title (see Variations) suits any title
const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets)
const header = { elements: [
// Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter (gotcha: quote-frontmatter)
folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'),
x: OUTER - GAP - TAB, y: HEAD }),
divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }),
{ kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body',
fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT,
color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3),
placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top
offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline
// Recto (odd): the essay's title, cut short with an ellipsis | divider | tab.
folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'),
x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards
divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }),
{ kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT),
lineHeight: LINE / TITLE_PT, color: col('muted'),
overflow: 'ellipsis-end', // stated, though default (gotcha: overflow-ellipsis-default)
placement: { anchor: { to: '#rule-odd', edge: 'left-of' },
offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } },
] };
// Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block.
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
用料
做法
#1 · 替换内置的蓝色书眉
const palette = {
ink: '#1f1b16', // text: a warm near-black, never #000
sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper)
muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper)
paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia
};
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' } })),
// Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color'. The
// built-in header and footer keep #295AA3, and design slots never read the palette
// (gotcha: palette-skips-designs), so every element colour here carries its hex.
{ id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
配置中的每种颜色都链接到这些条目之一。如果不定义header,引擎会画出内置书眉:右页排{title}、左页排{chapterTitle},下面一条1 pt的线,字体为Open Sans 600、8 pt、#295AA3蓝色(页眉和页脚)。把main-color指向深褐色,会改变文字的默认颜色,但那个书眉仍是蓝色,因为设计槽位从不读取调色板。所以这里每个书眉都完整写出,颜色用十六进制值。粗体、斜体和引用默认也取main-color,bodyText把它们设回墨色。
#2 · 用同一个标签搭出所有书眉
const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it
const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5
const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline
const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9
const TAB = 7.5; // mm: the tab's width
const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches
const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({
kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC',
fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'),
box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred
padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions
placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } },
});
const divider = ({ id, parity, from, edge, x }) => ({
kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5),
color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) },
size: { height: pt(TAB_H) } },
});
简短回答中把每个书眉的标签锚定到'page',因此偏移量从成品边缘量起。从top-right角出发,正的x会跑出页面,所以右页的标签取负值。分隔线挂在标签上,标题挂在分隔线上(元素定位)。标签的尺寸写成具名常量,因为其他部件都以它们为基准。分隔线高TAB_H。页码和两种标题共用一个9 pt的行框,而设计文本的基线位于行框自上而下80%处,所以无论字号多大,三者都站在同一条基线上。


#3 · 镜像页边距,按整行计算
const TRIM = { width: 132, height: 198 }; // mm
const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text)
const INNER = 15; // mm
const OUTER = 21; // mm: the outer margin, where the folio tabs hang
const LINES = 31; // whole lines of LEAD in the text block, so full pages end level
const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm
const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos
width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen
backgroundColor: col('paper'),
margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
left: mm(INNER), right: mm(OUTER), mirror: true },
};
设了mirror: true后,right页边距在右页是外侧,到左页就移到左边,所以OUTER始终是切口一侧的页边距,标签就挂在那里。下页边距由成品尺寸、上页边距和LINES推算,因此无论成品尺寸和上页边距怎么设,版心都正好是LINES行、每行LEAD(这里是31 × 14 pt)。每个排满的页面都在第31行结束,所以30和31这样的对页底部齐平。
#4 · 让章首页显示完整标题
const PLATE = 26; // mm: a square plate at the head of the text block
const opener = {
enabled: true,
minHeight: mm(58), // the text of every essay starts on the same line
slot: { elements: [
{ kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') },
placement: { anchor: { to: 'container', edge: 'top-left' },
size: { width: mm(PLATE), height: mm(PLATE) } } },
{ kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display',
fontSize: pt(48), lineHeight: 1, color: col('paper'),
placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where
size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default
{ kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC',
fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left',
overflow: 'wrap', // an opener shows the whole title; the running head cuts it
placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) },
size: { width: mm(MEASURE) } } },
] },
};
章首页用overflow: 'wrap'完整排出标题,并在标题行中每个\\处换行。只有通页的章首页才遵守这些换行,所以该级别设了span: 'page'(标题中的换行)。在书眉中,{chapterTitle}总是把标题放在一行里,省略号截断的就是这一行。序号来自标题的{num="IV"}属性,标牌旁的小型大写字母来自前置元数据中的subtitle。
#5 · 每篇随笔都从右页开始
headings: { // 400, the face's only weight: the default 700 would ask for one that is missing
fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'),
levels: [
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
// 'odd' puts every opener on a recto, adding a blank verso when one is needed;
// span: 'page' makes the design an opener, where a \\ in the title breaks the line.
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
],
},
只要设了headings对象,H1前的分页就会关闭,所以这里重新声明。用'odd'时,在右页结束的随笔后面会跟一个空白左页。西班牙语版就有一个,因为它的第IV章在第31页结束;这里第IV章在第32页(左页)结束,第V章从第33页开始。书眉的pages: 'body'让它们不出现在空白页和章首页上(章首页即第一个块是该标题的页面)。页脚的pages: 'opener'只在章首页印出页脚页码(文本元素)。
#6 · 页码从29开始
// 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even
// heads follow the book page (gotcha: parity-page1-recto); the folios start at 29.
const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } };
const doc = await buildWithFonts(
() => buildDocument({ markdown, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) });
parity按页面在书中的位置判断,不看它的页码。pageIndexOffset: 28给每页的序号加28,于是左右页、镜像页边距和奇偶书眉都跟随书中的页序。pageNumbering.startAt再把这些页编为29到33。没有这个偏移,本次排版的第一页无论印什么页码都是右页。这恰好适合第29页,但如果设startAt: 30,书名会落在第31页、随笔标题落在第32页,各自排错了跨页的一侧。
完整食谱
// ═══ Postext Cookbook · Nº 005 · Running heads by parity ═══════════════════════════════ // https://postext.dev/en/cookbook/running-heads-by-parity // Code: MIT · Text: Montaigne, tr. Cotton (PD, Gutenberg #3600); es: new translation (MIT) // Fonts: Baskervville, Libre Caslon Display, Alegreya SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // Pages 29 to 33 of a pocket Montaigne. Versos carry the book's title and rectos the essay's, // the folios sit in tabs in the outer margin, and an opener prints only a folio at the foot. import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'running-heads-by-parity'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: four colours with semantic ids; main-color points at the sepia const palette = { ink: '#1f1b16', // text: a warm near-black, never #000 sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper) muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper) paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia }; 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' } })), // Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color'. The // built-in header and footer keep #295AA3, and design slots never read the palette // (gotcha: palette-skips-designs), so every element colour here carries its hex. { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } }, ]; // #endregion const LEAD = 14; // pt: the body leading, the pitch of the baseline grid // #region tab: the folio tab and its divider, the two pieces every head is built from const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5 const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9 const TAB = 7.5; // mm: the tab's width const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({ kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'), box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } }, }); const divider = ({ id, parity, from, edge, x }) => ({ kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5), color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) }, size: { height: pt(TAB_H) } }, }); // #endregion // #region page: a pocket trim; the margins mirror, the text block holds 31 whole lines const TRIM = { width: 132, height: 198 }; // mm const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text) const INNER = 15; // mm const OUTER = 21; // mm: the outer margin, where the folio tabs hang const LINES = 31; // whole lines of LEAD in the text block, so full pages end level const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true }, }; // #endregion // #region answer: running heads by parity: book on the verso, essay on the recto, folios outside const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge const HEAD = 12; // mm from the top edge of the page to the top of the tab const CAP = t({ en: 60.5, es: 66.8 }); // mm: fitted so essay IV's head ends on a word // (gotcha: ellipsis-mid-word); a short running title (see Variations) suits any title const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets) const header = { elements: [ // Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter (gotcha: quote-frontmatter) folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'), x: OUTER - GAP - TAB, y: HEAD }), divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }), { kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body', fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT, color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3), placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline // Recto (odd): the essay's title, cut short with an ellipsis | divider | tab. folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'), x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }), { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body', fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT), lineHeight: LINE / TITLE_PT, color: col('muted'), overflow: 'ellipsis-end', // stated, though default (gotcha: overflow-ellipsis-default) placement: { anchor: { to: '#rule-odd', edge: 'left-of' }, offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } }, ] }; // Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block. const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener', anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] }; // #endregion // #region opener: the essay's numeral on a sepia plate; the title below it, set in full const PLATE = 26; // mm: a square plate at the head of the text block const opener = { enabled: true, minHeight: mm(58), // the text of every essay starts on the same line slot: { elements: [ { kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') }, placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(PLATE), height: mm(PLATE) } } }, { kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display', fontSize: pt(48), lineHeight: 1, color: col('paper'), placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default { kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC', fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display', fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left', overflow: 'wrap', // an opener shows the whole title; the running head cuts it placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) }, size: { width: mm(MEASURE) } } }, ] }, }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales) colorPalette, page: geometry, layout: { layoutType: 'single' }, bodyText: { // bold, italic and references default to main-color (the sepia): set to the ink fontFamily: 'Baskervville', fontSize: pt(10.2), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, // Hyphenation, optimal breaking and widow, orphan and runt control are on by default; a band // of 0.7–1.65 (default 0.6–2) evens the grey from line to line. At the default runt length, // 20 space widths, English essay IV ends on 'minds.' alone; at 16 it ends on 'our minds.'. minWordSpacing: 0.7, maxWordSpacing: 1.65, runtMinCharacters: 16, }, // #region levels: every essay opens on a recto; the heading draws the opener headings: { // 400, the face's only weight: the default 700 would ask for one that is missing fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // 'odd' puts every opener on a recto, adding a blank verso when one is needed; // span: 'page' makes the design an opener, where a \\ in the title breaks the line. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, ], }, // #endregion // Montaigne's quotations: a box with no fill keeps verse and gloss together. Its two 10.5 pt // margins and a two-line gloss (2 × 10.5 pt) make three whole lines, so the grid adds no space. calloutStyles: [{ id: 'quote', backgroundEnabled: false, padding: { top: pt(0), right: mm(8), bottom: pt(0), left: mm(8) }, marginTop: pt(LEAD * 0.75), marginBottom: pt(LEAD * 0.75) }], paragraphStyles: [ { id: 'verse', fontSize: pt(9.6), textAlign: 'center', firstLineIndent: pt(0) }, { id: 'gloss', fontSize: pt(7.6), lineHeight: pt(LEAD * 0.75), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0) }, // Markdown has no horizontal rule (--- prints as text; gap: markdown-extras), so the rule // over the colophon is eight em dashes of Alegreya SC that overlap into a 0.5 pt line. { id: 'end', fontFamily: 'Alegreya SC', fontSize: pt(8), color: col('sepia'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, // No italics in the colophon: a style's italic runs take bodyText.italicColor, the ink, // and would print darker than the muted words around them (gotcha: style-italic-colour). { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) }, ], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 70行 · content.en.md
title: "The Essays of Montaigne" subtitle: "Book the First" author: "Michel de Montaigne" --- # That the soul expends its passions \\ upon false objects, \\ where the true are wanting {num="IV"} A gentleman of my country, marvellously tormented with the gout, being importuned by his physicians totally to abstain from all manner of salt meats, was wont pleasantly to reply, that in the extremity of his fits he must needs have something to quarrel with, and that railing at and cursing, one while the Bologna sausages, and another the dried tongues and the hams, was some mitigation to his pain. But, in good earnest, as the arm when it is advanced to strike, if it miss the blow, and goes by the wind, it pains us; and as also, that, to make a pleasant prospect, the sight should not be lost and dilated in vague air, but have some bound and object to limit and circumscribe it at a reasonable distance. :::callout{type="quote"} :::paragraphs{style="verse"} *Ventus ut amittit vires, nisi robore densae* *Occurrant sylvae, spatio diffusus inani.* ::: :::paragraphs{style="gloss"} As the wind loses its force diffused in void space, unless it in its strength encounters the thick wood. — Lucan, III, 362 ::: ::: So it seems that the soul, being transported and discomposed, turns its violence upon itself, if not supplied with something to oppose it, and therefore always requires an object at which to aim, and whereon to act. Plutarch says of those who are delighted with little dogs and monkeys, that the amorous part that is in us, for want of a legitimate object, rather than lie idle, does after that manner forge and create one false and frivolous. And we see that the soul, in its passions, inclines rather to deceive itself, by creating a false and fantastical subject, even contrary to its own belief, than not to have something to work upon. After this manner brute beasts direct their fury to fall upon the stone or weapon that has hurt them, and with their teeth even execute revenge upon themselves for the injury they have received from another: :::callout{type="quote"} :::paragraphs{style="verse"} *Pannonis haud aliter, post ictum saevior ursa,* *Cui jaculum parva Lybis amentavit habena,* *Se rotat in vulnus, telumque irata receptum* *Impetit, et secum fugientem circuit hastam.* ::: :::paragraphs{style="gloss"} So the she-bear, fiercer for the Libyan’s dart, turns upon the wound and, attacking the spear, twists it as she flies. — Lucan, VI, 220 ::: ::: What causes of the misadventures that befall us do we not invent? what is it that we do not lay the fault to, right or wrong, that we may have something to quarrel with? It is not those beautiful tresses you tear, nor is it the white bosom that in your anger you so unmercifully beat, that with an unlucky bullet have slain your beloved brother; quarrel with something else. Livy, speaking of the Roman army in Spain, says that for the loss of the two brothers, their great captains, *flere omnes repente, et offensare capita*, “all at once wept and tore their hair.” ’Tis a common practice. And the philosopher Bion said pleasantly of the king, who by handfuls pulled his hair off his head for sorrow, “Does this man think that baldness is a remedy for grief?” Who has not seen peevish gamesters chew and swallow the cards, and swallow the dice, in revenge for the loss of their money? Xerxes whipped the sea, and wrote a challenge to Mount Athos; Cyrus employed a whole army several days at work, to revenge himself of the river Gyndas, for the fright it had put him into in passing over it; and Caligula demolished a very beautiful palace for the pleasure his mother had once enjoyed there. I remember there was a story current, when I was a boy, that one of our neighbouring kings, having received a blow from the hand of God, swore he would be revenged, and in order to it, made proclamation that for ten years to come no one should pray to Him, or so much as mention Him throughout his dominions, or, so far as his authority went, believe in Him; by which they meant to paint not so much the folly as the vainglory of the nation of which this tale was told. They are vices that always go together, but in truth such actions as these have in them still more of presumption than want of wit. Augustus Caesar, having been tossed with a tempest at sea, fell to defying Neptune, and in the pomp of the Circensian games, to be revenged, deposed his statue from the place it had amongst the other deities. Wherein he was still less excusable than the former, and less than he was afterwards when, having lost a battle under Quintilius Varus in Germany, in rage and despair he went running his head against the wall, crying out, “O Varus! give me back my legions!” for these exceed all folly, forasmuch as impiety is joined therewith, invading God Himself, or at least Fortune, as if she had ears that were subject to our batteries; like the Thracians, who when it thunders or lightens, fall to shooting against heaven with Titanian vengeance, as if by flights of arrows they intended to bring God to reason. Though the ancient poet in Plutarch tells us: :::callout{type="quote"} :::paragraphs{style="verse"} *Point ne se faut couroucer aux affaires,* *Il ne leur chault de toutes nos choleres.* ::: :::paragraphs{style="gloss"} We must not trouble the gods with our affairs; they take no heed of our angers and disputes. — Plutarch ::: ::: But we can never enough decry the disorderly sallies of our minds. # Whether the governor of a place \\ besieged ought himself \\ to go out to parley {num="V"} Quintus Marcius, the Roman legate in the war against Perseus, King of Macedon, to gain time wherein to reinforce his army, set on foot some overtures of accommodation, with which the king being lulled asleep, concluded a truce for some days, by this means giving his enemy opportunity and leisure to recruit his forces, which was afterwards the occasion of the king’s final ruin. Yet the elder senators, mindful of their forefathers’ manners, condemned this proceeding as degenerating from their ancient practice, which, they said, was to fight by valour, and not by artifice, surprises, and night-encounters; neither by pretended flight nor unexpected rallies to overcome their enemies; never making war till having first proclaimed it, and very often assigned both the hour and place of battle. :::paragraphs{style="end"} ———————— ::: :::paragraphs{style="colophon"} Chapters IV and V of Book the First of Montaigne’s essays, in Charles Cotton’s translation as revised by W. C. Hazlitt (1877), Project Gutenberg eBook #3600; notes shortened or omitted, misprints corrected. Set in Baskervville, Libre Caslon Display and Alegreya SC (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { // text, display and label faces (Libre Caslon Display has no italic) Baskervville: ['400', '400i'], 'Libre Caslon Display': ['400'], 'Alegreya SC': ['400', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // #region build: pages 29 to 33 of the book // 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even // heads follow the book page (gotcha: parity-page1-recto); the folios start at 29. const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) }); // #endregion工具包 · core, fonts, viewer:每道食谱都相同 · 235行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#给长标题一个短书眉
在标题行中写上书眉用的短标题{num="IV" short="Of false objects"},然后印这个属性而不是完整标题;没有short的章在那里什么也不印。
- { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
+ { kind: 'text', id: 'essay', content: '{attr.short}', parity: 'odd', pages: 'body',#随笔可以从任一侧开始
用'any'时,随笔从下一页开始,不论左右页,也不会添加空白页。标牌挂在版心的左上角,页脚页码居中排在它下方,所以两者在左页和右页上的位置相同。
- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },#把空白左页涂成强调色
带pages: 'blank'的框只印在为右页分页而添加的页面上:在西班牙语版中,它把第32页填成深褐色;英语版没有空白页,版面不变。
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
- anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
+ anchor: { to: 'container', edge: 'bottom' }, y: -HEAD }),
+ { kind: 'box', id: 'blank-leaf', pages: 'blank', style: { backgroundColor: col('sepia') },
+ placement: { anchor: page('top-left'),
+ size: { width: mm(TRIM.width), height: mm(TRIM.height) } } }] };常见问题
易错点
省略号会在词中间截断,并保留前面的空格
在postext 1.4.1中,overflow: 'ellipsis-end'在最后一个放得下的字符之后截断,而不是在词的边界处;截断点恰好在空格之后时,这个空格也会保留,所以书眉可能以'fals…'或'false …'结尾。对于已知的标题,把maxWidth调到刚好落在词尾;长标题则在标题属性里给一个简短的书眉标题。 页面设计中的文字、线条和框 →
易错点
相对容器的负偏移什么也渲染不出来
自动宽度的设计文本被限制在容器内,所以相对容器的负偏移会把它推出去,结果什么也不渲染。把这类元素用明确的mm偏移锚定到页面或出血上,或者给它们一个固定宽度。 锚定设计元素 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
段落样式没有斜体颜色
在postext 1.4.1中,段落样式可以设置color和boldColor,但没有italicColor:其中的斜体文字取bodyText.italicColor。于是一个淡色样式(小字、出处行)里的斜体书名会比周围的文字颜色更深。这类样式要么沿用正文的墨色,要么不用斜体。 段落样式 →
CAP是按每个版本中第IV篇随笔的标题调出来的,这里只有它出现在书眉中。第V篇的书眉仍会在单词中间截断(“Whether the governor of a place besieged ou…”),所以长标题多的书需要“变化”中的短书眉。- 在英语版中,把
minWordSpacing从0.7提高到0.75,第V篇的第一行(“Quintus Marcius, the Roman legate…”)在允许范围内找不到断点,词间距会拉到正常宽度的2.4倍。西班牙语版没有这样的行。每次修改后,都要检查截图列出的最松的几行。
致谢
- 文本
- Essays, Book I, chapters IV and V, in Charles Cotton’s translation edited by William Carew Hazlitt (1877) · Michel de Montaigne; Charles Cotton; William Carew Hazlitt · 公有领域
- Essais, livre I, chapitres IV et V: the 1595 French text in the Général Michaud edition (Firmin-Didot, 1907) · Michel de Montaigne · 公有领域
- Spanish translation, with its verse glosses · Postext Cookbook · 原创
- 字体
- Baskervville (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Alegreya SC (SIL OFL 1.1)

