成品一览
《Birds of the Estuary》是一本分两篇的袖珍野外指南,每篇一种生境。泥滩篇印成泥褐色,芦苇丛篇印成芦苇绿,每种颜色只写一次,写在开启这一生境的:::part中。每张分隔页都是满版出血的色块,上面有150 pt的罗马数字,这一生境的鸟种用米色列出。这一页的背面涂成同样的颜色,书口处有反白的篇色标。在鸟种页上,书口色标、页码、眉题、拉丁学名下的线、粗体的识别特征、项目符号和图版标签都取该篇的颜色。目录页上方是一条河口图带,每篇占一条自己颜色的色带,并标出分隔页的页码。封面、图带和四幅图版是用扩散模型生成的画,以JPEG位图载入;篇的调色板作用不到图片,所以图片保留自己的颜色。
这道食谱解答
- 怎样把书分成若干篇或部分,每篇有自己的颜色和隔页?
- 怎样在章首页和空白页上隐藏书眉,或者把空白左页涂成篇的颜色?
- 怎样添加自动更新的目录(前导符、页码、作者、篇名行)?
- 怎样给正文或框里的关键词(粗体或斜体)着色?
简短回答
// In the Markdown: :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"}
// Every colour below that is linked to 'band' takes #8c5e24 until the next part.
const parts = { // passed to the config as `parts`
// A divider opens on a recto by default. The break after it goes to the next recto too, so
// the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter).
breakAfter: { parity: 'odd' },
margins: { top: mm(125) }, // the fence's text starts low, under the title
design: { elements: [ // the divider: its container is the whole trim
box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10),
letterSpacing: pt(2.4), color: col('paper') },
at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left
// Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or
// "1"), on part pages only (gotcha: heading-number-placeholders).
text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9,
color: col('paper') }, below('part', 0)),
// The \\ in the title breaks the line here; the contents and the heads get one line.
text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98,
color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })),
rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })),
] },
// The back of the leaf: the same band, edge to edge, the part's tab in reverse at the
// fore-edge (a verso's is on the left) and its name at the foot.
versoDesign: { elements: [
box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center',
box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }),
text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4),
color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)),
] },
// The fence's list of species, in the paper colour on the band.
bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'),
orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } },
};
用料
- 功能
- 按篇配色篇页目录语义调色板粗体、斜体及其颜色出血色带与书边索引按页面角色显示书眉书眉与页码设计过的章首页标题属性编号标题标题样式不编号的章标题中的换行封面、扉页和版权页页面设计中的图片自定义资源类型图就放在这里固定位置的框与徽标换页与换栏
- 类型
- Alegreya, Zilla Slab, Barlow Condensed(SIL OFL 1.1)
- 素材
cover-1152.jpgcurlew-1200.jpgredshank-1200.jpgreedling-1200.jpgstrip-1536.jpgwarbler-1200.jpg- The cover: a curlew on the estuary mud, reeds in front (Generated With Diffusion Models, 原创)
- The contents strip: a curlew and a redshank on the mud, the reedbed beyond (Generated With Diffusion Models, 原创)
- Plate 1: Eurasian curlew (Generated With Diffusion Models, 原创)
- Plate 2: common redshank (Generated With Diffusion Models, 原创)
- Plate 3: bearded reedling (Generated With Diffusion Models, 原创)
- Plate 4: common reed warbler (Generated With Diffusion Models, 原创)
做法
#1 · 一个供各篇覆盖的调色板条目
const palette = {
ink: '#1f2624', // text: a green-tinted near-black
band: '#3c4b4f', // the house slate, before any part; each :::part brings its own
paper: '#f6f3ea', // the page, and the type set on a band
rule: '#d5d1c4', // hairlines
muted: '#61675f', // running heads, Latin names, the colophon
};
// The paletteId is the link a part's palette="band=#…" follows; the hex is written out too,
// as the palette alone would not reach design elements (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 link to 'main-color': point it at the ink, so nothing prints blue.
{ id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
篇按id覆盖调色板条目,所以一切应随生境变化的内容都链接到同一个条目band。它自己的值是石板灰,只在篇外印出(封面的眉题和目录下方说明的标题),配置中没有其他颜色用同一个十六进制值。col()把十六进制值写在id旁边,因为在Postext 1.4.1中,文档调色板从不作用到设计元素;篇的覆盖则通过id作用到它们。
#2 · 分隔页、它的背面和它的列表
这一步的代码见上面的简短回答。:::part在右页开启一张分隔页,parts.design在整个成品尺寸上排出它。围栏之间的Markdown从距顶边125 mm处、标题之下开始:先是用habitat段落样式排的几行生境介绍,然后是用bodyStyle排的鸟种列表。versoDesign只在分隔页背面为空白时才涂色(色块和反白色标),而breakAfter: { parity: 'odd' }把第一个鸟种推到下一个右页,使背面保持空白。{numberRoman}把篇的number="I"印成罗马数字(写"1"也会印成I),而鸟种用阿拉伯数字编为1到4。标题中的\\只在分隔页上换行;目录和书眉把标题排在一行。
#3 · 正文的强调色跟随篇
const bodyText = {
fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('band'), // the field marks: **Bill**, **Voice.**
italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false,
// Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters:
// 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt.
minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45,
// A runt is fixed with word spacing only: the default also tightens the tracking, which
// 1.4.1 measures but never paints (gotcha: runt-tracking-unpainted).
maxRuntTracking: 0,
};
const unorderedLists = { bulletChar: '▪', color: col('band'), // the field marks' bullets
marginTop: pt(0), marginBottom: pt(0) };
// The plates are numbered with the species, and their label is in the part's colour.
const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never',
counterFormat: 'decimal', ...t({
en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' },
es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' },
}) }];
const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true,
gap: mm(1.8) };
从一个:::part到下一个,凡是等于band自身值的文字颜色都换成该篇的值(:::part容器)。这里指的是粗体的识别特征、项目符号和图版标签。正文和斜体仍用墨色。图版有自己的资源类型,标签为Plate,从不重置,所以编号为1到4,与鸟种同步。
#4 · 鸟种首页
const species = { enabled: true, slot: { elements: [
text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5),
letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')),
// 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals.
text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5),
letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')),
text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05,
color: col('ink') }, below('kicker', 1.5, { width: 'fill' })),
text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5),
color: col('ink') }, below('name', 0.8)),
rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })),
] } };
鸟种首页印出标题的三个属性:status在眉题中编号之后,size在眉题右端,latin在名称之下。眉题和拉丁学名下的线链接到band,所以一个设计通用于两种生境。标题本身不设分页:每个鸟种都在:::pagebreak之后开始,或在篇之后的分页之后开始,所以它的页面算作正文页,印有书眉。尺寸不随标签改成大写,因为cm是单位符号。
#5 · 目录中每篇一条色带
const contents = {
levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600,
numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'),
numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }],
pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) },
leader: { char: '. ' },
subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5),
color: col('muted') }, // the Latin name, in italic by default
// A part row lays out this design with the part's number, title, page and palette.
parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [
box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }),
text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8),
letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')),
text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') },
at('left', 20, 0, 'container')),
text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'),
align: 'right' }, at('right', -2.5, 0, 'container')),
] } },
};
:::toc给每个:::part一行,在其中用该篇的编号、标题、分隔页页码和调色板排出toc.parts.design(目录),所以同一个框在泥滩篇印成褐色,在芦苇丛篇印成绿色。色带下的每个鸟种行显示编号、点状前导符和页码,下一行是取自latin属性的拉丁学名。
#6 · 书眉和色标中的篇
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label,
fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style },
placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank'
const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') };
const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity,
{ ...at(edge, 0, TAB.y), size: TAB.size },
{ fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } });
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso
] };
设置pages: 'body'后,书眉只印在正文页上。分隔页属于part页,涂色的背面属于blank页;封面和目录都以一个通页宽标题开头,因此算作首页。右页书眉在一行中印出{partTitle},书口处的色标把{partNumber}印在一个背景链接到band的框上。
完整食谱
// ═══ Postext Cookbook · Nº 019 · Parts in colour from one attribute ═══════════════════════ // https://postext.dev/en/cookbook/parts-in-colour // Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models // Fonts: Alegreya, Zilla Slab, Barlow Condensed (SIL OFL 1.1) · Needs postext ≥ 1.7.0 // A pocket field guide to two habitats. Each :::part names its own 'band' colour, and every // colour linked to 'band' takes it: the divider and its verso, the tab, the field marks. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'parts-in-colour'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: 'band' is the entry the parts override; the others keep their value const palette = { ink: '#1f2624', // text: a green-tinted near-black band: '#3c4b4f', // the house slate, before any part; each :::part brings its own paper: '#f6f3ea', // the page, and the type set on a band rule: '#d5d1c4', // hairlines muted: '#61675f', // running heads, Latin names, the colophon }; // The paletteId is the link a part's palette="band=#…" follows; the hex is written out too, // as the palette alone would not reach design elements (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 link to 'main-color': point it at the ink, so nothing prints blue. { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; // #endregion const TRIM = { width: 150, height: 200 }; // a pocket guide const MARGIN = { top: 22, bottom: 20, inner: 19, outer: 21 }; // mirrored; room for a thumb const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 110 mm, about 70 characters const LEAD = 14.5; // body leading in pt: the baseline grid const STRIP = 44; // the contents' picture strip, from the top edge (mm) const HEAD = { y: 12, gap: 8 }; // running heads from the top edge; folio to title (mm) const TAB = { y: 26, size: { width: mm(8), height: mm(24) } }; // the thumb tab, at the fore-edge const TITLE_DROP = 6; // mm from the foot of the contents strip to the title's box const BOOK = t({ en: 'Birds of the Estuary', es: 'Aves del estuario' }); const label = { fontFamily: 'Barlow Condensed', fontWeight: 600, textTransform: 'uppercase' }; const display = { fontFamily: 'Zilla Slab', fontWeight: 700 }; const at = (edge, x, y, to = 'page') => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const below = (id, y, size) => ({ ...at('below', 0, y, `#${id}`), ...(size && { size }) }); const fill = { size: { width: 'fill', height: 'fill' } }; const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap', align: 'left', ...style, placement }); const box = (id, color, placement) => ({ kind: 'box', id, style: { backgroundColor: color }, placement }); const rule = (id, color, w, placement) => ({ kind: 'rule', id, color, thickness: pt(w), placement }); // #region answer: a divider, its painted verso and its list, all in the part's own 'band' // In the Markdown: :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"} // Every colour below that is linked to 'band' takes #8c5e24 until the next part. const parts = { // passed to the config as `parts` // A divider opens on a recto by default. The break after it goes to the next recto too, so // the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter). breakAfter: { parity: 'odd' }, margins: { top: mm(125) }, // the fence's text starts low, under the title design: { elements: [ // the divider: its container is the whole trim box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }), text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10), letterSpacing: pt(2.4), color: col('paper') }, at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left // Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or // "1"), on part pages only (gotcha: heading-number-placeholders). text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9, color: col('paper') }, below('part', 0)), // The \\ in the title breaks the line here; the contents and the heads get one line. text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98, color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })), rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })), ] }, // The back of the leaf: the same band, edge to edge, the part's tab in reverse at the // fore-edge (a verso's is on the left) and its name at the foot. versoDesign: { elements: [ box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }), text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center', box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }), text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4), color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)), ] }, // The fence's list of species, in the paper colour on the band. bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'), orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } }, }; // #endregion // #region flow: the accents of the text, linked to 'band' so that each part retints them const bodyText = { fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('band'), // the field marks: **Bill**, **Voice.** italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters: // 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt. minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45, // A runt is fixed with word spacing only: the default also tightens the tracking, which // 1.4.1 measures but never paints (gotcha: runt-tracking-unpainted). maxRuntTracking: 0, }; const unorderedLists = { bulletChar: '▪', color: col('band'), // the field marks' bullets marginTop: pt(0), marginBottom: pt(0) }; // The plates are numbered with the species, and their label is in the part's colour. const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...t({ en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' }, es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' }, }) }]; const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true, gap: mm(1.8) }; // #endregion // #region species: each entry opens with its number and status, name, Latin and a 'band' rule const species = { enabled: true, slot: { elements: [ text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')), // 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals. text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5), letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')), text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05, color: col('ink') }, below('kicker', 1.5, { width: 'fill' })), text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5), color: col('ink') }, below('name', 0.8)), rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })), ] } }; // #endregion // #region contents: a band per part in that part's colour, then its species with leaders const contents = { levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600, numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'), numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }], pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) }, leader: { char: '. ' }, subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5), color: col('muted') }, // the Latin name, in italic by default // A part row lays out this design with the part's number, title, page and palette. parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [ box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }), text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')), text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') }, at('left', 20, 0, 'container')), text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'), align: 'right' }, at('right', -2.5, 0, 'container')), ] } }, }; // #endregion // #region running-heads: on body pages only, never on a divider or its verso; a 'band' tab const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label, fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style }, placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank' const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') }; const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity, { ...at(edge, 0, TAB.y), size: TAB.size }, { fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } }); const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio), head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)), head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)), head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio), tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso ] }; // #endregion // The cover and the contents are headings with no number and no contents entry. They get // no running heads either: a page-wide heading makes its page an 'opener', not 'body'. const unlisted = { numbered: false, toc: false, span: 'page' }; const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } }, text('kicker', '{subtitle}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8), color: col('band') }, at('top-left', MARGIN.inner, MARGIN.top)), // slate: no part yet text('title', '{titleText}', { ...display, fontSize: pt(50), lineHeight: 0.95, color: col('ink') }, below('kicker', 3, { width: mm(120) })), ] } }; // The contents open under a strip of the estuary: mud and waders, then the reeds. const contentsOpener = { enabled: true, minHeight: mm(STRIP), slot: { elements: [ { kind: 'image', id: 'strip', resourceId: 'strip', placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } }, text('title', '{titleText}', { ...display, fontSize: pt(26), color: col('ink') }, at('top-left', 0, STRIP - MARGIN.top + TITLE_DROP, 'container')), ] } }; 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: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText, unorderedLists, resourceTypes, captionStyle, headings: { ...display, levels: [ // breakBefore stated: the documented H1 page break would make each species page an // 'opener', with no running heads (gotcha: headings-drop-h1-break). :::pagebreak instead. { level: 1, fontSize: pt(22), breakBefore: { enabled: false }, numberingTemplate: '{1}', marginBottom: pt(0), advancedDesign: species }, ] }, headingStyles: [ { id: 'cover', ...unlisted, advancedDesign: cover }, { id: 'contents', ...unlisted, advancedDesign: contentsOpener }, ], toc: contents, parts, paragraphStyles: [ // The habitat's few lines on a divider: no indent, in the paper colour. { id: 'habitat', fontSize: pt(11), color: col('paper'), textAlign: 'left', firstLineIndent: pt(0), marginBottom: pt(LEAD / 2) }, // In the box its margins do not count; the leading adds air (gotcha: box-paragraph-margins). { id: 'colophon', fontFamily: 'Barlow Condensed', fontSize: pt(8), lineHeight: pt(15), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) }, ], // The note under the contents is pinned to the foot of the text block. calloutStyles: [{ id: 'about', placement: 'fixed', backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('band') }, body: { fontSize: pt(9.5), lineHeight: pt(13), firstLineIndent: pt(0), textAlign: 'left' } }], header, footer: { elements: [] }, // the default footer would centre a folio in Open Sans }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 95行 · content.en.md
title: "Birds of the Estuary" subtitle: "A pocket guide to the mudflats and the reedbeds" --- # Birds of \\ the Estuary {style="cover"} :::pagebreak # Contents {style="contents"} :::toc :::callout{type="about" title="How to use this guide"} The birds are grouped by habitat: the open mud of the lower estuary, and the reedbeds along its upper reaches. Each habitat has a colour, printed on its tab at the edge of the page, on the field marks in its species texts and on its row in the list above. Sizes are total length, from bill tip to tail tip. :::paragraphs{style="colophon"} Set in Alegreya, Zilla Slab and Barlow Condensed (SIL OFL) · Text: CC BY 4.0 · Plates: diffusion models. ::: ::: :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"} :::paragraphs{style="habitat"} Twice a day the tide drains out of the estuary and leaves a mile of shining mud. It looks empty, but every square metre hides thousands of worms, snails and shrimps, and waders fly in from half of Europe to feed on them. ::: 1. Eurasian Curlew 2. Common Redshank ::: # Eurasian Curlew {latin="Numenius arquata" size="50–60 cm" status="Winter visitor"} ::resource{id="curlew"} Our largest wader, and the easiest to name: no other bird on the mud carries so long and so curved a bill. Curlews work the soft mud at low water, walking slowly and probing to the hilt for worms and small crabs. - **Bill** very long and down-curved, longest in the female. - **Plumage** grey-brown, finely streaked; legs grey-blue. - **In flight** a white wedge runs up the back from the tail. **Voice.** A rising, far-carrying *cur-lee*, the sound of an estuary in winter; in spring, a bubbling song on the moors where it breeds. **Where and when.** On the mud from August to March, roosting in flocks on the saltmarsh at high tide. The curlew is declining across Europe and listed as Near Threatened: give a roost a wide berth. :::pagebreak # Common Redshank {latin="Tringa totanus" size="27–29 cm" status="Resident"} ::resource{id="redshank"} The noisiest bird on the marsh. A redshank sees you first and tells everything within half a mile, bobbing nervously and piping as it flies off; it has long been called the warden of the marshes. - **Legs** bright orange-red; the bill red at the base, dark at the tip. - **Plumage** plain grey-brown in winter, mottled browner in summer. - **In flight** a broad white trailing edge to the wing, and white up the back. **Voice.** A ringing *tew-hu-hu*, often the first sound you hear on the marsh, and a frantic *teuk-teuk-teuk* when it is alarmed. **Where and when.** All year round on the creeks and the tideline, picking shrimps and small snails from the mud. It nests in tussocks of saltmarsh grass, often in loose colonies. :::part{number="II" title="The \\ Reedbeds" palette="band=#51702f"} :::paragraphs{style="habitat"} Where the river meets the tide, the common reed grows in beds taller than a person, green in summer and gold in winter. Few birds live in them, and most of those stay hidden among the stems. ::: 3. Bearded Reedling 4. Eurasian Reed Warbler ::: # Bearded Reedling {latin="Panurus biarmicus" size="14.5–17 cm" status="Resident"} ::resource{id="reedling"} A small, long-tailed bird that seldom leaves the reeds, and you will usually hear it first: a pinging call from deep in the reedbed, then a party of birds whirring low over the plumes on short, rounded wings. - **Male** head blue-grey, with a drooping black moustache (and no beard). - **Body** warm tawny; the long, graduated tail is half the bird. - **Female** brown-headed and without the moustache. **Voice.** A metallic *ping*, like a tiny bell, repeated as the party moves. **Where and when.** All year in large reedbeds. It eats insects in summer and reed seeds in winter, and swallows grit in autumn to grind them. Once classed with the tits (Linnaeus named it *Parus biarmicus*), it now has a family of its own, the Panuridae. :::pagebreak # Eurasian Reed Warbler {latin="Acrocephalus scirpaceus" size="13 cm" status="Summer visitor"} ::resource{id="warbler"} A plain brown warbler, heard far more often than seen. Its song runs on from inside the reeds all through a summer day: a slow, rhythmic chatter, *jit-jit-jit, churr-churr*, with phrases borrowed from other birds. - **Upperparts** unstreaked warm brown; underparts buff, throat whitish. - **Head** a flat forehead and a strong, pointed bill. - **Movement** sidles up the stems, often gripping two at once. **Where and when.** A summer visitor from late April to September, wintering in Africa south of the Sahara. Its nest is a deep cup woven round three or four reed stems, and it is one of the cuckoo’s commonest hosts.`; // content.<lang>.md, inlined by the Cookbook // The plates' captions, and the alt text of every drawing. const CAPTIONS = t({ en: { cover: 'A curlew on the mud, with reeds in front.', strip: 'A curlew and a redshank on the mud, with the reedbed beyond.', curlew: 'Adult at low water. The female’s bill is the longer.', redshank: 'Adult by a saltmarsh creek, on the red legs that give it its name.', reedling: 'Male on a reed stem. The female has a plain brown head.', warbler: 'Singing from the reeds, one foot on each stem.', }, es: { cover: 'Un zarapito en el fango, con carrizos delante.', strip: 'Un zarapito y un archibebe en el fango, y el carrizal al fondo.', curlew: 'Adulto en bajamar. La hembra tiene el pico más largo.', redshank: 'Adulto junto a un caño de la marisma, sobre las patas rojas que lo delatan.', reedling: 'Macho en un tallo de carrizo. La hembra tiene la cabeza parda.', warbler: 'Cantando en el carrizal, con una pata en cada tallo.', } }); // Every picture is a JPEG in assets/, cut to the shape of its frame, declared at its pixels. const drawing = (id, [w, h], more) => ({ id, typeId: 'plate', kind: 'bitmap', bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0, altText: CAPTIONS[id], ...more }); // Plates stand where ::resource{id="…"} is; since 1.5 an inline figure keeps a line of space // below it too. const resources = [drawing('cover', [1152, 1536]), drawing('strip', [1536, 451]), ...['curlew', 'redshank', 'reedling', 'warbler'].map((id) => drawing(id, [1200, 600], { caption: CAPTIONS[id], placement: { position: 'here' } }))]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { Alegreya: ['400', '400i', '700'], 'Zilla Slab': ['600', '700'], // text, display 'Barlow Condensed': ['400', '600', '700'] }; // and labels // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all(resources.map(({ bitmap: b }) => loadImage(b.fileId, asset(b.fileId)))); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: BOOK });工具包 · 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上的食谱文件夹 ↗
变化
#加第三种生境
在第一个盐沼鸟种之前加一个围栏,就多出第三种颜色,连同它的分隔页、涂色的背面和目录行,配置不用改。同时去掉目录下方的说明,因为第三条色带和它的鸟种需要这块空间。
+:::part{number="III" title="The \\ Saltmarsh" palette="band=#6b4a7a"}
+:::
+
+# Common Shelduck {latin="Tadorna tadorna" size="58–67 cm" status="Resident"}#让鸟种与分隔页相对
去掉跳到下一个右页的分页后,第一个鸟种从分隔页背面开始,也就没有空白的背面留给versoDesign涂色。
- breakAfter: { parity: 'odd' },常见问题
易错点
parts.versoDesign需要parts.breakAfter的parity为'odd'
只有当parts.breakAfter { enabled: true, parity: 'odd' }让篇章页的背面(左页)空出来时,这一页才会被绘制。 篇页 →
易错点
{number}/{chapterNumber}打印H1编号;{numberRoman}只用于篇
{number}和{chapterNumber}打印标题格式化后的编号,但{numberRoman}、{numberDecimal}等其他数字变体只在篇页上填入。在numberingTemplate中格式化章号({1:I}),或者通过属性传入。 编号标题 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
段落样式的外边距在框内不起作用
在postext 1.4.1中,嵌套在:::callout里的:::paragraphs容器会忽略其样式的marginTop和marginBottom,所以排在注释正文下面的一行小字会紧贴着正文。给这个样式更大的lineHeight,让首行上方留出空隙,或者把这一行放到框外。 段落样式 →
易错点
消除孤字时收紧的字距可能根本不绘制
在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 篇按值比较来给文字换色:任何等于
band自身值的文字颜色都会随篇切换,即使它链接到另一个条目或没有链接。这个十六进制值只留给band。 - 色标印
{partNumber},而它在篇外为空,所以第一个:::part之前的正文页会得到一个空的石板灰色标。让每个正文页都在某个篇之内。 - 图片和行内色块保留其中写定的颜色。这里的图版从代码中的
HABITAT取得各自生境的颜色,所以在:::part中加新颜色时,也要加到那里。
致谢
- 文本
- 原创文字, CC BY 4.0
- 图片
- The cover: a curlew on the estuary mud, reeds in front · Generated With Diffusion Models · 原创
- The contents strip: a curlew and a redshank on the mud, the reedbed beyond · Generated With Diffusion Models · 原创
- Plate 1: Eurasian curlew · Generated With Diffusion Models · 原创
- Plate 2: common redshank · Generated With Diffusion Models · 原创
- Plate 3: bearded reedling · Generated With Diffusion Models · 原创
- Plate 4: common reed warbler · Generated With Diffusion Models · 原创
- 字体
- Alegreya (SIL OFL 1.1) · Zilla Slab (SIL OFL 1.1) · Barlow Condensed (SIL OFL 1.1)

