成品一览
一本收录杰拉德·曼利·霍普金斯四首自然诗的小诗集,140 × 216 mm,文本采用1918年初版。简扉页之后,一张蕨草和花楸的物影照片作为卷首插图,与用Italiana排的书名页相对。目录按罗马数字和斜体诗题列出每首诗,用疏排的点通向页码。每首诗另起一页,上方是灰绿色的罗马数字和斜体诗题。每一行诗是一个段落,缩进与1918年版相同:Pied Beauty的诗行按韵脚缩进,The Windhover的长行转行后悬挂缩进4 em,是其缩进深度的两倍。诗节之间空一行,除了霍普金斯自己断开的地方,没有任何词被断开。日期排在每首诗之下,右对齐,最后一首之后是一枝花楸。诗页在页脚居中印页码。
这道食谱解答
- 怎样排诗歌:每句一行,诗节间留空,折行悬挂缩进,不断词?
- 空行不起作用时,怎样在两个块之间加额外的垂直间距?
- 怎样添加自动更新的目录(前导符、页码、作者、篇名行)?
- 怎样排题词、献词、署名,或带大号引号的引文框?
- 怎样排不编号的插图:饰图、小花饰、标志?
简短回答
// A poem is one :::paragraphs{style="verse"} container with a paragraph per line, so no line
// runs on into the next. An indented line starts with spaces, two to an em, and a :::space
// line leaves one line of the grid between stanzas (blank lines only separate paragraphs):
// :::paragraphs{style="verse"}
// The world is charged with the grandeur of God.
//
// It will flame out, like shining from shook foil;
//
// :::space
//
// And for all this, nature is never spent;
// :::
const verse = {
id: 'verse',
textAlign: 'left', // ragged: a line that turns over is not stretched to the measure
hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents
// Verse is never hyphenated; ragged text is not in 1.4.1 either (gotcha: ragged-no-hyphenation).
hyphenation: false,
};
// A paragraph loses its leading spaces, and hangingIndent overrides firstLineIndent, so in verse
// each pair of leading spaces becomes an em space behind a word joiner, where the trim stops.
const indentVerse = (md) => md.replace(/:::paragraphs\{style="verse"\}\n[\s\S]*?\n:::\n/g,
(poem) => poem.replace(/^((?: {2})+)(?=\S)/gm, (s) => `\u2060${'\u2003'.repeat(s.length / 2)}`));
// Hook-up: paragraphStyles: [verse, …] and buildDocument({ markdown: indentVerse(markdown) }).
用料
- 功能
- 段落样式显式竖向间距目录缩进、对齐与段落间距转义与字面字符编号标题设计过的章首页标题样式不编号的章标题属性页面设计中的图片封面、扉页和版权页文档元数据分节书眉书眉与页码自定义资源类型图就放在这里语义调色板按页面角色显示书眉
- 类型
- Sorts Mill Goudy, Italiana, Marcellus SC(SIL OFL 1.1)
- 素材
- The frontispiece photogram of fern and rowan and the rowan ornament, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)
做法
#1 · 一行诗就是一个段落
代码见上文的简短回答。Postext会把一个Markdown段落里的各行合成一行,所以一首诗需要一行一个段落。诗行样式让这些段落齐左,因为两端对齐会把转行的前半截拉满整个行长。它把一行其余的部分悬挂缩进4 em,超过押韵诗行1 em和2 em的缩进,这样转出的部分不会被读成单独一行(段落样式)。段落会丢掉行首的空格,全身空格也不例外,而带悬挂缩进的样式会忽略首行缩进。因此indentVerse把行首的每两个空格写成一个跟在连接符(word joiner)后面的全身空格;连接符是零宽字符,去除行首空白时到它就停下。互相押韵的诗行保持相同的缩进,和1918年版一样。空行不增加间距。每个诗节间隔是容器内的一行:::space,它留出一行网格,出现在页首时会被丢弃(:::space)。

#2 · 诗周围的文字也有样式
const lineStyles = [
{ id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover
{ id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'),
textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right
];
The Windhover上方的献词和每首诗下方的地点与日期都不是诗行,所以各自是只含一个段落的:::paragraphs容器,使用自己的样式。这两个样式都不设行距,外边距都是一行(15 pt),所以诗行仍落在正文的网格上。日期排在诗下一行处,右对齐,用8 pt的Marcellus SC,也就是页码的字体。
#3 · 每首诗在自己的编号下另起一页
// '{1:I}' numbers the poems I to IV and {number} prints it (gotcha: heading-number-placeholders).
// The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same
// line; numeral and title fill 4, or 5 if the title wraps (gotcha: overflow-ellipsis-default).
const HEAD_LINES = 6;
const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [
{ kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22),
color: col('sage'), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy',
italic: true, fontSize: pt(17), color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) },
size: { width: 'fill' } } },
] } };
// Parity 'any': the next page, recto or verso (restated: gotcha headings-drop-h1-break).
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
这个标题不印它的文字,而是在栏里印一个版面设计。在这个设计里,{number}是按numberingTemplate: '{1:I}'格式化的计数,从I到IV,{titleText}是诗题(通栏与高级版面设计)。这一级标题没有下外边距,minHeight让每个标题都占六行网格(31.75 mm)。罗马数字和一行诗题占其中四行,诗题折行时占五行,所以每首诗都从页面上的同一行开始。在第6页上,这一行放的是献词。奇偶设为'any'时分页到下一页,不论左右页,所以诗与诗之间没有空白页。
#4 · 前置部分由标题组成
// Headings too, each on a page of its own, with no number, contents entry or folio.
// A style restates the break, or inherits the poems' (gotcha: style-inherits-break).
const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] },
breakBefore: { enabled: true, parity: 'any' } };
const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center',
placement: onPage(y), ...extra });
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const design = (...elements) => ({ enabled: true, slot: { elements } });
const front = [
{ id: 'half-title', ...leaf,
advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) },
{ id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column clips its design
image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }),
face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203,
{ italic: true, color: col('muted') })) },
{ id: 'title-page', ...leaf, advancedDesign: design(
face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter
{ letterSpacing: pt(2.4), textTransform: 'uppercase' }),
// A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }),
face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80,
{ italic: true, color: col('sage') }),
image('sprig', onPage(96, 30)),
face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182,
{ letterSpacing: pt(1.8), color: col('muted') })) },
{ id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false },
fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) },
];
标题样式可以分页到新的一页并在那里画出版面设计,所以简扉页、卷首插图和书名页各是一个标题,各有自己的样式。numbered: false让它们不参与计数,第一首诗仍是I。toc: false让它们不进目录,空的页脚让它们的页面不带页码(标题样式)。这些样式设为通栏,因为栏内的版面设计会被裁到版心之内,那样会把满版出血的插图裁到页边距,还会切掉题注。题注来自标题的caption属性;书名页上的作者、书名和副书名来自frontmatter。
#5 · 目录自动填写
// The toc centres each numeral on its line, not on the baseline (gotcha: toc-number-baseline);
// left at the titles' size, these Italiana numerals land on it, where 9 pt ones rode high.
const contents = {
levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana',
numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }],
pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) },
leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
};
:::toc列出样式允许进入目录的每个标题,罗马数字出自同一个模板,页码取标题所在页的页码标签。构建会反复排版,直到这些页码标签不再变化(目录)。目录页本身的标题Contents使用带numbered: false的样式;没有它,Contents就会成为第I首诗。前导符'. '在每个点后加一个空格。点是右对齐的,所以各条目之间的点上下对齐;它们用页码的字体和颜色印出,因为toc.leader没有颜色设置。
#6 · 装饰图案是一种不带标签的资源类型
// No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts
// it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here', width: 0.2, align: 'center' } };
正文中的图片是资源,每个资源都有一个类型。装饰图案类型的题注前缀为空,花楸枝也没有题注,所以下面不会印出“Figure 1”之类的标签(资源类型)。这个类型的defaultPlacement把花楸枝放在::resource{id="sprig"}所在的位置,居中,宽度为行长的五分之一,所以Inversnaid下面的尾花只占一行Markdown。resourceTypes会替换默认列表,所以这本书没有Figure或Table类型。要保留它们,就把defaultResourceTypes()展开放在装饰图案之前。
完整食谱
// ═══ Postext Cookbook · Nº 015 · Poems set line by line ═════════════════════════════ // https://postext.dev/en/cookbook/poetry-collection // Code: MIT · Text: G. M. Hopkins, Poems, 1918 (PD) · Plate and ornament: drawn in code // Fonts: Sorts Mill Goudy, Italiana, Marcellus 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 ('en' | 'es') const RECIPE = 'poetry-collection'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#26221f', // the text: a warm near-black sage: '#56673f', // the one accent: numerals, the plate's ground, the ornament's leaves sepia: '#8c5f3a', // used for the rowan berries only muted: '#746a60', // dates, folios, leaders and the colophon paper: '#fbf8f2', // the page }; // The hex travels with the id: designs do not read the palette (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 accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.sage, model: 'hex' } }, ]; const TRIM_W = 140, TRIM_H = 216; // mm: a poetry trim, tall enough for a sonnet's turnovers const LEAD = 15; // pt: the body's leading, the grid every line of verse sits on // #region answer: verse: a paragraph per line, indents kept, turnovers that hang, stanza space // A poem is one :::paragraphs{style="verse"} container with a paragraph per line, so no line // runs on into the next. An indented line starts with spaces, two to an em, and a :::space // line leaves one line of the grid between stanzas (blank lines only separate paragraphs): // :::paragraphs{style="verse"} // The world is charged with the grandeur of God. // // It will flame out, like shining from shook foil; // // :::space // // And for all this, nature is never spent; // ::: const verse = { id: 'verse', textAlign: 'left', // ragged: a line that turns over is not stretched to the measure hangingIndent: em(4), // a turned line hangs past the 1 and 2 em indents // Verse is never hyphenated; ragged text is not in 1.4.1 either (gotcha: ragged-no-hyphenation). hyphenation: false, }; // A paragraph loses its leading spaces, and hangingIndent overrides firstLineIndent, so in verse // each pair of leading spaces becomes an em space behind a word joiner, where the trim stops. const indentVerse = (md) => md.replace(/:::paragraphs\{style="verse"\}\n[\s\S]*?\n:::\n/g, (poem) => poem.replace(/^((?: {2})+)(?=\S)/gm, (s) => `\u2060${'\u2003'.repeat(s.length / 2)}`)); // Hook-up: paragraphStyles: [verse, …] and buildDocument({ markdown: indentVerse(markdown) }). // #endregion // #region lines: the lines around a poem: a dedication above it, a place and a date below const lineStyles = [ { id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover { id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'), textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right ]; // #endregion // #region poem-head: every poem opens a page under its numeral and its title // '{1:I}' numbers the poems I to IV and {number} prints it (gotcha: heading-number-placeholders). // The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same // line; numeral and title fill 4, or 5 if the title wraps (gotcha: overflow-ellipsis-default). const HEAD_LINES = 6; const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [ { kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22), color: col('sage'), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy', italic: true, fontSize: pt(17), color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) }, size: { width: 'fill' } } }, ] } }; // Parity 'any': the next page, recto or verso (restated: gotcha headings-drop-h1-break). const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead, marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } }; // #endregion // #region front: the half-title, the frontispiece, the title page and the contents // Headings too, each on a page of its own, with no number, contents entry or folio. // A style restates the break, or inherits the poems' (gotcha: style-inherits-break). const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] }, breakBefore: { enabled: true, parity: 'any' } }; const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', placement: onPage(y), ...extra }); const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement }); const design = (...elements) => ({ enabled: true, slot: { elements } }); const front = [ { id: 'half-title', ...leaf, advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) }, { id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column clips its design image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }), face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203, { italic: true, color: col('muted') })) }, { id: 'title-page', ...leaf, advancedDesign: design( face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter { letterSpacing: pt(2.4), textTransform: 'uppercase' }), // A multiple of the size, never pt() (gotcha: design-lineheight-multiple). face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }), face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80, { italic: true, color: col('sage') }), image('sprig', onPage(96, 30)), face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182, { letterSpacing: pt(1.8), color: col('muted') })) }, { id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false }, fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) }, ]; // #endregion // #region contents: what :::toc prints for each poem: numeral, italic title, leader, page // The toc centres each numeral on its line, not on the baseline (gotcha: toc-number-baseline); // left at the titles' size, these Italiana numerals land on it, where 9 pt ones rode high. const contents = { levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana', numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }], pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) }, leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up }; // #endregion // #region ornament: a resource type for artwork that prints no caption and no label // No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts // it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred. const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here', width: 0.2, align: 'center' } }; // #endregion const proseStyles = [ // the note on the text and the colophon, under the contents { id: 'note', fontSize: pt(9.5), lineHeight: pt(13) }, { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ]; const folio = (pages) => ({ kind: 'text', id: `folio-${pages}`, content: '{pageNumber}', pages, fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }); const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, // The list replaces Figure and Table; a book with figures spreads defaultResourceTypes() in. resourceTypes: [ornament], page: { // mirror: left is the inner margin; 150 dpi is for the screen sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(22), bottom: mm(24), left: mm(22), right: mm(18), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: { // the note's prose: justified, hyphenated (en-us), spaces 0.8–1.6 of normal fontFamily: 'Sorts Mill Goudy', fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6, }, headings: { fontFamily: 'Sorts Mill Goudy', fontWeight: 400, color: col('ink'), levels: [poems, { level: 2, fontFamily: 'Marcellus SC', fontSize: pt(9), lineHeight: pt(LEAD), color: col('sage'), marginTop: pt(0), marginBottom: pt(0) }], }, headingStyles: front, toc: contents, paragraphStyles: [verse, ...lineStyles, ...proseStyles], header: { elements: [] }, // no running heads: each poem starts a page under its own title footer: { elements: [folio('opener'), folio('body')] }, // centred; never on a blank page }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 188行 · content.en.md
title: "Pied Beauty" subtitle: "Four poems of the natural world" author: "Gerard Manley Hopkins" --- # Pied Beauty {style="half-title"} # Fern and Rowan {style="plate" caption="Fern and rowan: the ‘flitches of fern’ and the ‘beadbonny ash’ of Inversnaid"} # Pied Beauty {style="title-page"} # Contents {style="contents"} :::toc :::space{lines=2} ## A note on the text :::paragraphs{style="note"} This selection follows the first edition of the poems, edited by Robert Bridges in 1918, nearly thirty years after Hopkins’s death. Its spelling, accents and indents are kept, and lines that rhyme with one another start the same distance in. A line too long for the page is turned over, and the turnover is set deeper than the lines around it, so that it reads as part of the line above. No word is broken unless Hopkins broke it, as he did at the end of the first line of *The Windhover*. Under each poem is the date from Bridges’s notes, with the place when he gives one. ::: :::paragraphs{style="colophon"} Set in Sorts Mill Goudy, Italiana and Marcellus SC (SIL Open Font License). Text from Project Gutenberg eBook 22403, accents and indents from Wikisource. Plate and ornament drawn for this edition. ::: # God’s Grandeur :::paragraphs{style="verse"} The world is charged with the grandeur of God. It will flame out, like shining from shook foil; It gathers to a greatness, like the ooze of oil Crushed. Why do men then now not reck his rod? Generations have trod, have trod, have trod; And all is seared with trade; bleared, smeared with toil; And wears man’s smudge and shares man’s smell: the soil Is bare now, nor can foot feel, being shod. :::space And for all this, nature is never spent; There lives the dearest freshness deep down things; And though the last lights off the black West went Oh, morning, at the brown brink eastward, springs— Because the Holy Ghost over the bent World broods with warm breast and with ah! bright wings. ::: :::paragraphs{style="date"} 23 February 1877 ::: # The Windhover :::paragraphs{style="dedication"} *To Christ our Lord* ::: :::paragraphs{style="verse"} I caught this morning morning’s minion, king- dom of daylight’s dauphin, dapple-dawn-drawn Falcon, in his riding Of the rolling level underneath him steady air, and striding High there, how he rung upon the rein of a wimpling wing In his ecstacy! then off, off forth on swing, As a skate’s heel sweeps smooth on a bow-bend: the hurl and gliding Rebuffed the big wind. My heart in hiding Stirred for a bird,—the achieve of, the mastery of the thing! :::space Brute beauty and valour and act, oh, air, pride, plume, here Buckle! AND the fire that breaks from thee then, a billion Times told lovelier, more dangerous, O my chevalier! :::space No wonder of it: shéer plód makes plough down sillion Shine, and blue-bleak embers, ah my dear, Fall, gall themselves, and gash gold-vermillion. ::: :::paragraphs{style="date"} St Beuno’s, 30 May 1877 ::: # Pied Beauty :::paragraphs{style="verse"} Glory be to God for dappled things— For skies of couple-colour as a brinded cow; For rose-moles all in stipple upon trout that swim: Fresh-firecoal chestnut-falls; finches’ wings; Landscape plotted and pieced—fold, fallow, and plough; And áll trádes, their gear and tackle and trim. :::space All things counter, original, spare, strange; Whatever is fickle, freckled (who knows how?) With swift, slow; sweet, sour; adazzle, dim; He fathers-forth whose beauty is past change: Praise him. ::: :::paragraphs{style="date"} St Beuno’s, Tremeirchion, summer 1877 ::: # Inversnaid :::paragraphs{style="verse"} This darksome burn, horseback brown, His rollrock highroad roaring down, In coop and in comb the fleece of his foam Flutes and low to the lake falls home. :::space A windpuff-bonnet of fáwn-fróth Turns and twindles over the broth Of a pool so pitchblack, féll-frówning, It rounds and rounds Despair to drowning. :::space Degged with dew, dappled with dew Are the groins of the braes that the brook treads through, Wiry heathpacks, flitches of fern, And the beadbonny ash that sits over the burn. :::space What would the world be, once bereft Of wet and of wildness? Let them be left, O let them be left, wildness and wet; Long live the weeds and the wilderness yet. ::: :::paragraphs{style="date"} 28 September 1881 ::: ::resource{id="sprig"}`; // content.<lang>.md, inlined by the Cookbook // #region art: a photogram of fern and rowan, and a rowan sprig for the ornament let seed = 1877; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2), 16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')) .join('')}`; const f = (n) => n.toFixed(1); const path = (d, fill, a = 1) => `<path d="${d}" fill="${fill}" fill-opacity="${a}"/>`; const ring = (pts) => `M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`; const disk = (x, y, r, fill, a = 1) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" ` + `fill="${fill}" fill-opacity="${a}"/>`; const PX = 10; // a drawing w × h mm has a viewBox in tenths of a millimetre, w·PX × h·PX const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" ` + `height="${h * PX}" viewBox="0 0 ${w * PX} ${h * PX}">${body}</svg>`; // A cubic Bézier and its unit normal at t. const bez = ([p0, p1, p2, p3], t) => { const u = 1 - t; const coord = (i) => u * u * u * p0[i] + 3 * u * u * t * p1[i] + 3 * u * t * t * p2[i] + t * t * t * p3[i]; const dt = (i) => 3 * u * u * (p1[i] - p0[i]) + 6 * u * t * (p2[i] - p1[i]) + 3 * t * t * (p3[i] - p2[i]); const [dx, dy] = [dt(0), dt(1)]; const len = Math.hypot(dx, dy) || 1; return { x: coord(0), y: coord(1), a: Math.atan2(dy, dx), nx: -dy / len, ny: dx / len }; }; // A stalk: the curve drawn as a filled band tapering from w0 to w1. const stalk = (curve, w0, w1) => { const left = []; const right = []; for (let i = 0; i <= 40; i++) { const p = bez(curve, i / 40); const w = (w0 + (w1 - w0) * (i / 40)) / 2; left.push([p.x + p.nx * w, p.y + p.ny * w]); right.unshift([p.x - p.nx * w, p.y - p.ny * w]); } return ring([...left, ...right]); }; // A leaf blade from its base (x, y) along angle a: length l, half-width w, `teeth` serrations. const blade = (x, y, a, l, w, teeth = 0) => { const [c, s] = [Math.cos(a), Math.sin(a)]; const at = (u, v) => [x + u * c - v * s, y + u * s + v * c]; const side = (sign) => Array.from({ length: 25 }, (_, i) => { const t = i / 24; let h = w * Math.sin(Math.PI * t ** 0.85) ** 0.9; if (teeth && t > 0.25) h *= 1 - 0.18 * ((t * teeth) % 1); return at(l * t, sign * h); }); return ring([...side(1), ...side(-1).reverse()]); }; // A fern frond: a curving rachis, alternate pinnae longest near the base, each a comb of lobes. function fern(curve, reach, lobe) { const d = [stalk(curve, 20, 3)]; const n = 30; for (let i = 3; i < n; i++) { const t = i / n; const p = bez(curve, t); const side = i % 2 ? 1 : -1; const len = reach * Math.sin(Math.PI * Math.min(1, (1 - t) * 1.25) / 2) ** 1.2; const pa = p.a + side * (1.05 - 0.35 * t); // pinnae lean towards the tip const [cx, cy] = [Math.cos(pa), Math.sin(pa)]; const sweep = side * 0.18; // each pinna arches a little towards the tip const axis = [[p.x, p.y], [p.x + cx * len / 3, p.y + cy * len / 3], [p.x + Math.cos(pa - sweep) * len * 0.68, p.y + Math.sin(pa - sweep) * len * 0.68], [p.x + Math.cos(pa - sweep * 2) * len, p.y + Math.sin(pa - sweep * 2) * len]]; d.push(stalk(axis, 5, 1.2)); const k = Math.max(2, Math.round(len / (lobe * 0.82))); for (let j = 0; j < k; j++) { const s = (j + 0.5) / (k + 0.3); const q = bez(axis, s); const size = lobe * (1.05 - s * 0.6) * (0.92 + rand() * 0.16); for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 0.95, size * 1.6, size * 0.5)); } const tip = bez(axis, 1); d.push(blade(tip.x, tip.y, tip.a, lobe * 1.2, lobe * 0.35)); } return d.join(''); } // Rowan: a woody stem, pinnate leaves of serrated leaflets and a dome of berries. function rowan(curve, leaves, cluster, scale = 1) { const d = [stalk(curve, 22 * scale, 8 * scale)]; const dots = []; for (const [t, side, len] of leaves) { const p = bez(curve, t); const a = p.a + side * 0.9; const l = len * scale; const rachis = [[p.x, p.y], [p.x + Math.cos(a) * l / 3, p.y + Math.sin(a) * l / 3], [p.x + Math.cos(a + side * 0.12) * l * 2 / 3, p.y + Math.sin(a + side * 0.12) * l * 2 / 3], [p.x + Math.cos(a + side * 0.25) * l, p.y + Math.sin(a + side * 0.25) * l]]; d.push(stalk(rachis, 5 * scale, 2 * scale)); for (let j = 0; j < 6; j++) { const q = bez(rachis, 0.18 + j * 0.15); const size = (130 - j * 6) * scale; for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 1.25, size, size * 0.22, 7)); } const tip = bez(rachis, 1); d.push(blade(tip.x, tip.y, tip.a, 125 * scale, 27 * scale, 7)); } const [cx, cy, r] = cluster; // berries on short stalks, heaped into a dome for (let i = 0; i < 34; i++) { const a = -Math.PI * (0.08 + rand() * 0.84); const dist = r * Math.sqrt(rand()); const [bx, by] = [cx + Math.cos(a) * dist * 1.25, cy + Math.sin(a) * dist * 0.8]; d.push(stalk([[cx, cy + r * 0.5], [cx, cy], [bx, by + 20 * scale], [bx, by]], 3 * scale, 2 * scale)); dots.push([bx, by, (17 + rand() * 5) * scale]); } return { d: d.join(''), dots }; } // The frontispiece: a photogram, the specimens left in paper white on a brushed field of // sage, the way Anna Atkins printed her ferns in cyanotype. function photogram(w, h) { const [W, H] = [w * PX, h * PX]; const [x0, y0, x1, y1] = [70, 70, W - 70, H - 170]; // the brushed-on coating const phase = [rand(), rand(), rand()].map((r) => r * 6.3); const wob = (v, amp) => amp * (Math.sin(v / 37 + phase[0]) * 0.5 + Math.sin(v / 13 + phase[1]) * 0.3 + Math.sin(v / 5.3 + phase[2]) * 0.2) + (rand() - 0.5) * amp * 0.4; const edge = []; for (let x = x0; x <= x1; x += 10) edge.push([x, y0 + wob(x, 7)]); for (let y = y0; y <= y1; y += 10) edge.push([x1 + wob(y, 16), y]); for (let x = x1; x >= x0; x -= 10) edge.push([x, y1 + wob(x + 99, 7)]); for (let y = y1; y >= y0; y -= 10) edge.push([x0 + wob(y + 55, 16), y]); const out = [`<defs><radialGradient id="g" cx="0.42" cy="0.38" r="0.85">` + `<stop offset="0" stop-color="${mix('sage', 'paper', 0.06)}"/>` + `<stop offset="1" stop-color="${mix('sage', 'ink', 0.4)}"/></radialGradient></defs>`, path(ring(edge), 'url(#g)')]; // a single path with a gradient fill; only its outline is ragged const frond = fern([[520, 1960], [380, 1350], [640, 700], [930, 190]], 400, 26); const tree = rowan([[1090, 1960], [1160, 1450], [960, 1060], [1000, 640]], [[0.2, -1, 300], [0.4, 1, 280], [0.58, -1, 290], [0.76, 1, 250]], [1000, 600, 150], 0.85); // Opaque, as a photogram is: where the two specimens overlap they print one white. out.push(path(frond, palette.paper), path(tree.d, palette.paper)); for (const [x, y, r] of tree.dots) out.push(disk(x, y, r, palette.paper), disk(x, y - r * 0.2, r * 0.22, mix('sage', 'paper', 0.5))); return svgOf(w, h, out.join('')); } // The ornament: a rowan leaf laid flat, with a bunch of berries at its tip. function sprig() { const [w, h] = [36, 12]; const rib = [[16, 66], [90, 56], [170, 56], [236, 60]]; const d = [stalk(rib, 6, 3)]; for (let j = 0; j < 5; j++) { const q = bez(rib, 0.12 + j * 0.19); const size = 50 - j * 3; for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 1.1, size, size * 0.22, 8)); } const tip = bez(rib, 1); d.push(blade(tip.x, tip.y, tip.a, 44, 10.5, 8)); const out = [path(d.join(''), palette.sage)]; for (const [dx, dy, r] of [[34, -16, 9], [44, 2, 10], [30, 14, 9], [52, -12, 8.5], [58, 10, 8], [22, -2, 8]]) { const [bx, by] = [262 + dx, 60 + dy]; out.push(path(stalk([[240, 64], [252, 66], [bx - 10, by + 2], [bx, by]], 2.6, 1.8), palette.sage), disk(bx, by, r, palette.sepia)); } return svgOf(w, h, out.join('')); } const art = { plate: photogram(TRIM_W, TRIM_H), sprig: sprig() }; for (const [id, svg] of Object.entries(art)) await loadSvg(`${id}.svg`, svg); // #endregion // Nothing cites them: designs show the plate and the sprig, ::resource sets the tailpiece. const svgResource = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText, createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } }); const resources = [ svgResource('plate', TRIM_W, TRIM_H, 'A photogram of a fern frond and a sprig of rowan in ' + 'berry, left white on a brushed sage ground.'), svgResource('sprig', 36, 12, 'Ornament: a rowan leaf with a bunch of berries.'), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded before the first build (gotcha: fonts-first). None of the three ships a bold. const FONTS = { 'Sorts Mill Goudy': ['400', '400i'], Italiana: ['400'], 'Marcellus SC': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // :::toc lists the page each poem lands on: the build lays out again until those settle. const verses = indentVerse(markdown); // the leading spaces of verse, as em spaces const doc = await buildWithFonts(() => buildDocument({ markdown: verses, resources }, config()), markdown); showPages(doc, { title: 'Pied Beauty · four poems by Gerard Manley Hopkins' });工具包 · 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上的食谱文件夹 ↗
变化
#转行悬挂得更深
悬挂缩进越深,转行的部分离页边距越远,适合经常转行的诗。
- hangingIndent: em(4), // 转行悬挂在1 em和2 em缩进之外
+ hangingIndent: em(6), // 转行悬挂在1 em和2 em缩进之外#每行都从页边距开始
不用indentVerse时,Postext会去掉行首空格,每一行诗都齐左起排;只有转行部分仍然悬挂。
-const verses = indentVerse(markdown); // 诗行的行首空格,改写为全身空格
+const verses = markdown; // 行首空格交给裁剪处理#每首诗都从右页开始
奇偶设为'odd'时,每首诗都从下一个右页开始。全书从八页增加到十一页,三个空白的左页不印页码,因为页码按页面角色筛选。
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
- marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
+ marginBottom: pt(0), breakBefore: { enabled: true, parity: 'odd' } };#用阿拉伯数字给诗编号
模板的计数样式会同时改变诗页上和目录里的编号。
-const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
+const poems = { level: 1, numberingTemplate: '{1}', advancedDesign: poemHead,常见问题
易错点
目录编号比条目的基线略高
在postext 1.4.1中,:::toc把每个条目的编号绘制在行的垂直居中位置,而不是文字的基线上,所以章号在标题旁略微偏高:在16 pt的标题旁约高0.7 mm,无论给它什么字体或字号都是如此。目前没有toc选项能调整它,所以付印前要按实际尺寸查看目录页。 目录 →
易错点
{number}/{chapterNumber}打印H1编号;{numberRoman}只用于篇
{number}和{chapterNumber}打印标题格式化后的编号,但{numberRoman}、{numberDecimal}等其他数字变体只在篇页上填入。在numberingTemplate中格式化章号({1:I}),或者通过属性传入。 编号标题 →
易错点
标题样式会继承其级别的分页设置
headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 每行诗后面都要留一个空行。两行之间没有空行就是同一个段落,诗会像散文一样重排。
- 给诗行样式单独设置
textAlign。段落样式继承正文的对齐方式,两端对齐的诗行会把每个转行的前半截拉满整个行长。 - 以年份加句点(如“1877. ”)或以连字符加空格开头的诗行会变成列表项,因为每一行都是一个段落。在它前面加一个连接符(U+2060),就像
indentVerse在缩进前所做的那样。 - 要让两个词一起转行,用不换行空格(U+00A0)把它们连起来,如第5页的the soil和bright wings.。这在纯文本行里有效;如果行里有斜体或粗体,引擎仍会在那里断开。
pages保留为'all'的页脚元素在空白页上也会印出。这里的页码是两个元素,一个用于章首页,一个用于正文页,所以空白的左页保持空白。
致谢
- 文本
- “God’s Grandeur”, “The Windhover”, “Pied Beauty” and “Inversnaid”, with the places and dates from the editor’s notes, in the first edition of Poems of Gerard Manley Hopkins (1918); accents and indents checked against the proofread Wikisource transcription of that edition · Gerard Manley Hopkins; Robert Bridges (editor) · 公有领域
- The note on the text, the plate’s caption and the colophon · Ignacio Ferro · CC BY 4.0
- 图片
- The frontispiece photogram of fern and rowan and the rowan ornament, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- 字体
- Sorts Mill Goudy (SIL OFL 1.1) · Italiana (SIL OFL 1.1) · Marcellus SC (SIL OFL 1.1)


