成品一览
《Frankenstein》开头几页,排成129 × 198 mm的袖珍经典本:封面是Mont Blanc上的闪电,背面留空;半书名页;Walton的船困在冰中的卷首插图,与书名页相对;版权信息排在书名页背面的低处;献给Godwin的献词在Milton的题词上方;一页目录;以及1818年的Preface。这十页编为i到x,但只有目录和Preface印出页码。Letter I在右页开始小说,上方是北极光,页码1与对页x的位置相同。这封信一直排到Walton的署名。目录里Preface旁的ix和信旁的1都取自排好的页面,PDF阅读器也按同样方式标注页面。封面、卷首插图和北极光是用扩散模型生成的画,以JPEG位图加载;出版社标志和冰晶用代码绘制。
这道食谱解答
- 怎样用罗马数字给前置部分编页码,并从第1章起重新从1开始?
- 怎样制作封面、简扉页、扉页和版权页?
- 怎样添加自动更新的目录(前导符、页码、作者、篇名行)?
- 怎样插入有意留白的页面,或让某一部分从新的跨页开始?
- 怎样排题词、献词、署名,或带大号引号的引文框?
简短回答
// The page counter starts in lower-case roman: the cover is page i. After the Preface, two
// directives in the Markdown count again from 1 on the next recto:
// :::pagebreak{parity="odd"}
// :::numbering{format="decimal" startAt=1}
// :::numbering takes effect on the next page that starts (gotcha: numbering-next-page). Level 1
// breaks to 'odd', so here the pagebreak changes nothing; it finds the recto when a level breaks
// with parity 'any' or not at all. Never with 'always-odd': the blank pages would stack up.
const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i
// One folio design, centred under the text block. {pageNumber} prints the page's own label,
// so the same design sets "x" in the front matter and "1" on the first page of the novel.
const folio = (pages) => text(`folio-${pages}`, '{pageNumber}', 'Playfair Display SC', 8.5,
at('top', 0, 9), { letterSpacing: pt(0.8), pages }); // lower case sets as small capitals
// The front matter is a heading style, unnumbered and left out of the contents (a heading
// can opt back in with {toc="true"}). It prints no running heads and puts a folio on every
// page of the section. Parity 'any', because the contents fall on a verso.
const front = {
id: 'front', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
header: { elements: [] }, footer: { elements: [folio('all')] }, ...frontLook,
};
// The novel keeps the document's furniture: heads on its body pages, the folio on openers.
const footer = { elements: [folio('opener')] };
// Hook-up in config(): page: { pageNumbering }, headingStyles: [front] and footer at the top
// level. frontLook, set above, is the look of the prelims' openers.
用料
- 功能
- 罗马数字页码的前置部分目录分节书眉封面、扉页和版权页换页与换栏标题样式不编号的章分节版式页面设计中的图片从右页开始的章段落样式设计过的章首页标题属性文档元数据书眉与页码按页面角色显示书眉对称页边距导出PDFPDF书签作为资源的图和表
- 类型
- Fanwood Text, Playfair Display SC, Cinzel(SIL OFL 1.1)
- 素材
band-1400.jpgcover-1000.jpgplate-1000.jpg- The cover: lightning over Mont Blanc and the lake of Geneva (Generated With Diffusion Models, 原创)
- The frontispiece: a brig beset in pack ice, a dog sledge far off (Generated With Diffusion Models, 原创)
- Letter I’s opener: northern lights over the frozen Neva (Generated With Diffusion Models, 原创)
做法
#1 · 每一页都是一个带设计的标题
// Each leaf is a heading, so it opens its page, gets a PDF bookmark and takes a design. The
// leaves are unnumbered, left out of the contents and print no heads or folio (blind folios).
// Parity 'any', or each would inherit level 1's odd break (gotcha: style-inherits-break).
const blind = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
header: { elements: [] }, footer: { elements: [] } };
const PLATE_H = 126; // mm: the frontispiece, as wide as the text block; its caption hangs below
// mm from the trim: the imprint's eleven lines end on the text block's last baseline.
const IMPRINT_TOP = 116.5; // re-tune it whenever the imprint changes
const leaves = [
// i: bled to the trim. Type below the text block's foot would drop the heading's reserve.
{ id: 'cover', ...blind, advancedDesign: design([
image('cover', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
label('author', '{author}', 9, onPage(22), 'bone', 2.6),
text('title', '{title}', 'Fanwood Text', 56, onPage(31), { color: col('bone'), lineHeight: 1 }),
text('subtitle', '{subtitle}', 'Fanwood Text', 13, onPage(53), { ...on('ice'), italic: true }),
label('series', '{attr.publisher}', 7, onPage(168), 'ice', 2),
]) },
// iii: the half title waits for a recto, so the back of the cover, ii, is left blank.
{ id: 'half', ...blind, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: design([
text('title', '{title}', 'Playfair Display SC', 17, at('top', 0, 34), { letterSpacing: pt(3) }),
image('crystal', at('top', 0, 45, { width: 6 })),
]) },
{ id: 'plate', ...blind, advancedDesign: design([ // iv: faces the title page
image('plate', at('top-left', 0, 0, { width: 'fill' })),
text('caption', '{attr.caption}', 'Fanwood Text', 8.8,
at('top', 0, PLATE_H + 5, { width: 80 }), { italic: true, lineHeight: 1.3 }),
label('source', '{attr.source}', 7.5, at('top', 0, PLATE_H + 16), 'muted', 1.3),
]) },
{ id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute
// {publishDate} prints because the year is quoted (gotcha: quote-frontmatter).
label('edition', 'The text of {publishDate}', 7.5, at('top', 0, 22), 'accent', 1.8),
text('title', '{title}', 'Playfair Display SC', 33, at('top', 0, 30),
{ fontWeight: 700, letterSpacing: pt(0.4), lineHeight: 1 }),
text('subtitle', '{subtitle}', 'Fanwood Text', 14, at('top', 0, 44), { italic: true }),
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
placement: at('top', 0, 60, { width: 14 }) },
label('author', '{author}', 10, at('top', 0, 66), 'ink', 2.4),
image('mark', at('top', 0, 126, { width: 11 })),
label('publisher', '{attr.publisher}', 7.5, at('top', 0, 140), 'muted', 1.5),
]) },
// vi: the section's margins set the small print low, in a 70 mm measure against the outer
// margin. With no element the design would print the title (gotcha: invisible-heading).
{ id: 'imprint', ...blind, margins: { top: mm(IMPRINT_TOP), left: mm(TRIM_W - OUTER - 70) },
advancedDesign: design([{ kind: 'box', id: 'none', style: { backgroundColor: col('paper') },
placement: { ...at('top-left', 0, 0), size: { width: pt(0.1), height: pt(0.1) } } }]) },
// vii: dedication and epigraph, under a heading whose only element is an ornament, which
// also keeps its title from printing (gotcha: invisible-heading).
{ id: 'quiet', ...blind,
advancedDesign: design([image('crystal', at('top', 0, 24, { width: 6 }))], 34) },
];
// :::paragraphs blocks set the small print, the dedication, the epigraph and Walton's close;
// the dedication and the verse take one paragraph per line, so no line of them can reflow.
const VERSE_IN = 22; // mm: the indent that centres the verse as a block
const SOURCE_IN = 55; // mm: 'Paradise Lost' ends under the end of the longest line of verse
const prelimStyles = [
// In ink, not muted: a paragraph style has no italic colour, so its italic title would take
// bodyText.italicColor and print darker than its words (gotcha: style-italic-colour).
{ id: 'small-print', fontSize: pt(7.6), lineHeight: pt(10.4), textAlign: 'left',
firstLineIndent: pt(0), spaceBetween: pt(5.2) },
// Small capitals, with the italic lines of the 1818 page, leaded as one inscription.
{ id: 'dedication', fontFamily: 'Playfair Display SC', fontSize: pt(10), lineHeight: pt(15),
textAlign: 'center', firstLineIndent: pt(0) },
{ id: 'verse', fontSize: pt(10), textAlign: 'left', firstLineIndent: mm(VERSE_IN),
marginTop: pt(LEAD * 4) }, // on the body's 14 pt leading, which a style inherits
{ id: 'source', fontFamily: 'Cinzel', fontSize: pt(7.5), textAlign: 'left',
firstLineIndent: mm(SOURCE_IN), marginTop: pt(4), color: col('muted') },
{ id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, // the letter's close
];
标题会另起一页,并成为PDF书签;它的样式中的设计负责放置这一页的文字和图片。用numbered: false和toc: false,这些页不计入章数,也不进目录。空的页眉和页脚给了它们印刷行所说的暗页码:书名页不印页码,但仍然是第v页,所以Preface能是第ix页。只有半书名页保留了奇数页换页,这让封面背面留空。版权信息、献词、题词和Walton的结尾是:::paragraphs块,用摘录末尾的样式排。
#2 · 目录的页码取自版面
// Entries take the body's face and ink, and start on the Preface's first baseline, facing it.
const contents = {
levels: [{ level: 1, fontSize: pt(12), lineHeight: pt(PREFACE.lead) }],
// The folio's face: "ix" in small capitals, then a plain 1. The leader takes it too.
pageNumber: { fontFamily: 'Playfair Display SC', fontSize: pt(10), color: col('accent'),
width: mm(8) },
leader: { char: '. ', gap: mm(2.5) },
// A second line from the heading's to="…" attribute: whom each letter is written to.
subtitle: { enabled: true, attr: 'to', fontSize: pt(9.5), color: col('muted') },
};
:::toc列出其样式允许的每个标题,并附上标题所在页的标签(目录)。Contents和Preface共用front样式,它设了toc: false,Preface用{toc="true"}覆盖它。Preface和Letter I都不编号,所以它们的条目不印章号。信的条目第二行是它的to="…"属性。页码用页码字体排。条目与Preface第一行从同一条基线开始,所以两个对页对齐。
#3 · 小说在右页开始,上方是北极光
const BAND = 104; // mm from the trim to the foot of the drawing, which fades into the paper
// minHeight ends AIR mm below it and the first line takes the next grid line. to: 'bleed' is the
// trim until page.cutLines adds a bleed; 3 mm drop the foot 1.8 mm, still above that line.
const AIR = 2;
const opener = design([
image('band', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
label('to', '{attr.to}', 7.5, onPage(19), 'ice', 2),
text('title', '{titleText}', 'Playfair Display SC', 46, onPage(25),
{ color: col('bone'), lineHeight: 1 }),
text('dateline', '{attr.dateline}', 'Fanwood Text', 10.5, onPage(44),
{ italic: true, color: col('bone') }),
// minHeight counts from the text block, TOP below the trim. The texts reserve their height,
// the drawing nothing (gotcha: opener-image-no-reserve), so minHeight sets the first line.
], BAND - TOP + AIR);
// Walton's letters come before Chapter I: they take the novel's opener but no number.
const letter = { id: 'letter', numbered: false };
这幅画从页面顶端出血,底部渐隐到纸色里,信就从那里开始。在Postext 1.4.1中,图像从不计入章首页预留的空间(它的文字、线条和方框会计入,即使锚定在页面上),所以只靠minHeight就把第一行放到画的下方(通栏与高级设计)。眉题和日期行来自标题的to和dateline属性。Letter I还带着{style="letter"},这个样式唯一的设置是numbered: false,因为Walton的信排在Chapter I之前,没有章号。
#4 · 书眉随小说回来
const HEAD_Y = 12.5; // mm from the trim to the heads' line
const HEAD_GAP = 8; // mm from the outer margin, where the folio hangs, to the words beside it
const head = (id, content, parity, edge, x, extra = {}) => ({ ...label(id, content, 7.5,
at(edge, x, HEAD_Y, { to: 'page' }), 'muted', 1.4), align: 'left', parity, pages: 'body',
...extra }); // pages: 'body' keeps them off the opener
const numeral = { fontFamily: 'Playfair Display SC', fontSize: pt(8.5), fontWeight: 400,
letterSpacing: pt(0.6), color: col('ink') }; // folios in the folio face, as on the openers
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, numeral),
head('verso-title', '{title}', 'even', 'top-left', OUTER + HEAD_GAP),
head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + HEAD_GAP)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, numeral),
] };
除非样式替换,每一节都沿用文档的页眉和页脚。前置部分把两者都替换了,letter样式没有,所以从第1页起,小说在正文页上印出这些书眉,在章首页上印出简短回答里定义的底部页码。{pageNumber}印出所在页的标签,所以folio()辅助函数用同一个设计在书脊一侧排x、另一侧排1。
#5 · PDF带着同样的页码
await loadFonts(FONTS, markdown);
// :::toc lists each heading with the label of the page it lands on; buildDocument lays the
// document out again until those labels stop moving (ix for the Preface, 1 for Letter I).
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Frankenstein · the opening leaves' });
// The PDF gets /PageLabels from the same labels: a viewer numbers its pages i–x, then 1, 2.
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`);
buildDocument会重复排版,直到目录里的页面标签不再变化,所以调用一次就够了。renderToPdf把这些标签写进PDF:在能读取页面标签的阅读器里,页码栏从i走到x,然后从1开始,“转到第1页”会打开Letter I。每个标题都成为以其文字命名的书签,所以这些页用的是朴素的标题:Cover、Half title、Frontispiece、Title page、Imprint、Dedication。
完整食谱
// ═══ Postext Cookbook · Nº 006 · Front matter: roman folios, then page 1 ═══════════ // https://postext.dev/en/cookbook/front-matter-roman-to-arabic // Code: MIT · Text: M. & P. B. Shelley, 1818 (PD) · Pictures: diffusion models; marks drawn in code // Fonts: Fanwood Text, Playfair Display SC, Cinzel (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'front-matter-roman-to-arabic'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#1c1d21', // text: a cold near-black accent: '#3d5a70', // steel blue, the one accent on paper: contents folios, the rule, the marks muted: '#6a665f', // running heads, sources, the second lines of the contents paper: '#fbf8f2', // the page night: '#131a24', ice: '#a9c6d6', bone: '#efe9dc', // the drawings' inks, and the type on them }; 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.accent, model: 'hex' } }, ]; const TRIM_W = 129, TRIM_H = 198; // mm: a pocket classic const TOP = 21, BOTTOM = 23, INNER = 17, OUTER = 15; // margins, mm; heads align to OUTER const MEASURE = TRIM_W - INNER - OUTER; // 97 mm: the text block, and the frontispiece's width const LEAD = 14; // body leading in pt: 31 lines fill the text block const PREFACE = { size: 9.8, lead: 13.3 }; // pt: the preface, a size under the novel's 10.5/14 // Design-slot shorthands. at(): a placement from an anchor's edge; edge 'top' centres it. const wide = (width) => (width === 'fill' ? 'fill' : mm(width)); const at = (edge, x, y, { width, to = 'container' } = {}) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width !== undefined && { size: { width: wide(width) } }) }); const onPage = (y) => at('top', 0, y, { to: 'page' }); // centred on the page, y mm from the trim const on = (id) => ({ color: col(id) }); // a design text's ink // overflow 'wrap', or a text too wide ends in '…' (gotcha: overflow-ellipsis-default). A number // lineHeight multiplies the size; never pt() (gotcha: design-lineheight-multiple). const text = (id, content, font, size, placement, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', placement, ...extra }); const label = (id, content, size, placement, ink, tracking) => text(id, content, 'Cinzel', size, placement, { fontWeight: 600, letterSpacing: pt(tracking), textTransform: 'uppercase', ...on(ink) }); // the label voice: Cinzel capitals, tracked const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement }); // A heading's design. minHeight (mm) where the text must start lower than the elements reach. const design = (elements, minHeight) => ({ enabled: true, slot: { elements }, ...(minHeight !== undefined && { minHeight: mm(minHeight) }) }); // The prelims' openers: a centred title in small capitals on white space, and the preface's // text a size smaller than the novel's, on its own leading. const FRONT_DROP = 46; // mm from the text block's top to the first line of text const frontLook = { advancedDesign: design([text('title', '{titleText}', 'Playfair Display SC', 19, at('top', 0, 14), { letterSpacing: pt(1.6) })], FRONT_DROP), bodyStyle: { fontSize: pt(PREFACE.size), lineHeight: pt(PREFACE.lead) }, }; // #region answer: roman folios for the front matter and arabic from Letter I, in one design // The page counter starts in lower-case roman: the cover is page i. After the Preface, two // directives in the Markdown count again from 1 on the next recto: // :::pagebreak{parity="odd"} // :::numbering{format="decimal" startAt=1} // :::numbering takes effect on the next page that starts (gotcha: numbering-next-page). Level 1 // breaks to 'odd', so here the pagebreak changes nothing; it finds the recto when a level breaks // with parity 'any' or not at all. Never with 'always-odd': the blank pages would stack up. const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i // One folio design, centred under the text block. {pageNumber} prints the page's own label, // so the same design sets "x" in the front matter and "1" on the first page of the novel. const folio = (pages) => text(`folio-${pages}`, '{pageNumber}', 'Playfair Display SC', 8.5, at('top', 0, 9), { letterSpacing: pt(0.8), pages }); // lower case sets as small capitals // The front matter is a heading style, unnumbered and left out of the contents (a heading // can opt back in with {toc="true"}). It prints no running heads and puts a folio on every // page of the section. Parity 'any', because the contents fall on a verso. const front = { id: 'front', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' }, header: { elements: [] }, footer: { elements: [folio('all')] }, ...frontLook, }; // The novel keeps the document's furniture: heads on its body pages, the folio on openers. const footer = { elements: [folio('opener')] }; // Hook-up in config(): page: { pageNumbering }, headingStyles: [front] and footer at the top // level. frontLook, set above, is the look of the prelims' openers. // #endregion // #region leaves: the cover and the preliminary leaves, headings that print a design only // Each leaf is a heading, so it opens its page, gets a PDF bookmark and takes a design. The // leaves are unnumbered, left out of the contents and print no heads or folio (blind folios). // Parity 'any', or each would inherit level 1's odd break (gotcha: style-inherits-break). const blind = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' }, header: { elements: [] }, footer: { elements: [] } }; const PLATE_H = 126; // mm: the frontispiece, as wide as the text block; its caption hangs below // mm from the trim: the imprint's eleven lines end on the text block's last baseline. const IMPRINT_TOP = 116.5; // re-tune it whenever the imprint changes const leaves = [ // i: bled to the trim. Type below the text block's foot would drop the heading's reserve. { id: 'cover', ...blind, advancedDesign: design([ image('cover', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })), label('author', '{author}', 9, onPage(22), 'bone', 2.6), text('title', '{title}', 'Fanwood Text', 56, onPage(31), { color: col('bone'), lineHeight: 1 }), text('subtitle', '{subtitle}', 'Fanwood Text', 13, onPage(53), { ...on('ice'), italic: true }), label('series', '{attr.publisher}', 7, onPage(168), 'ice', 2), ]) }, // iii: the half title waits for a recto, so the back of the cover, ii, is left blank. { id: 'half', ...blind, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: design([ text('title', '{title}', 'Playfair Display SC', 17, at('top', 0, 34), { letterSpacing: pt(3) }), image('crystal', at('top', 0, 45, { width: 6 })), ]) }, { id: 'plate', ...blind, advancedDesign: design([ // iv: faces the title page image('plate', at('top-left', 0, 0, { width: 'fill' })), text('caption', '{attr.caption}', 'Fanwood Text', 8.8, at('top', 0, PLATE_H + 5, { width: 80 }), { italic: true, lineHeight: 1.3 }), label('source', '{attr.source}', 7.5, at('top', 0, PLATE_H + 16), 'muted', 1.3), ]) }, { id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute // {publishDate} prints because the year is quoted (gotcha: quote-frontmatter). label('edition', 'The text of {publishDate}', 7.5, at('top', 0, 22), 'accent', 1.8), text('title', '{title}', 'Playfair Display SC', 33, at('top', 0, 30), { fontWeight: 700, letterSpacing: pt(0.4), lineHeight: 1 }), text('subtitle', '{subtitle}', 'Fanwood Text', 14, at('top', 0, 44), { italic: true }), { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'), placement: at('top', 0, 60, { width: 14 }) }, label('author', '{author}', 10, at('top', 0, 66), 'ink', 2.4), image('mark', at('top', 0, 126, { width: 11 })), label('publisher', '{attr.publisher}', 7.5, at('top', 0, 140), 'muted', 1.5), ]) }, // vi: the section's margins set the small print low, in a 70 mm measure against the outer // margin. With no element the design would print the title (gotcha: invisible-heading). { id: 'imprint', ...blind, margins: { top: mm(IMPRINT_TOP), left: mm(TRIM_W - OUTER - 70) }, advancedDesign: design([{ kind: 'box', id: 'none', style: { backgroundColor: col('paper') }, placement: { ...at('top-left', 0, 0), size: { width: pt(0.1), height: pt(0.1) } } }]) }, // vii: dedication and epigraph, under a heading whose only element is an ornament, which // also keeps its title from printing (gotcha: invisible-heading). { id: 'quiet', ...blind, advancedDesign: design([image('crystal', at('top', 0, 24, { width: 6 }))], 34) }, ]; // :::paragraphs blocks set the small print, the dedication, the epigraph and Walton's close; // the dedication and the verse take one paragraph per line, so no line of them can reflow. const VERSE_IN = 22; // mm: the indent that centres the verse as a block const SOURCE_IN = 55; // mm: 'Paradise Lost' ends under the end of the longest line of verse const prelimStyles = [ // In ink, not muted: a paragraph style has no italic colour, so its italic title would take // bodyText.italicColor and print darker than its words (gotcha: style-italic-colour). { id: 'small-print', fontSize: pt(7.6), lineHeight: pt(10.4), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(5.2) }, // Small capitals, with the italic lines of the 1818 page, leaded as one inscription. { id: 'dedication', fontFamily: 'Playfair Display SC', fontSize: pt(10), lineHeight: pt(15), textAlign: 'center', firstLineIndent: pt(0) }, { id: 'verse', fontSize: pt(10), textAlign: 'left', firstLineIndent: mm(VERSE_IN), marginTop: pt(LEAD * 4) }, // on the body's 14 pt leading, which a style inherits { id: 'source', fontFamily: 'Cinzel', fontSize: pt(7.5), textAlign: 'left', firstLineIndent: mm(SOURCE_IN), marginTop: pt(4), color: col('muted') }, { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, // the letter's close ]; // #endregion // #region contents: what :::toc prints: titles, dotted leaders, the labels each page prints // Entries take the body's face and ink, and start on the Preface's first baseline, facing it. const contents = { levels: [{ level: 1, fontSize: pt(12), lineHeight: pt(PREFACE.lead) }], // The folio's face: "ix" in small capitals, then a plain 1. The leader takes it too. pageNumber: { fontFamily: 'Playfair Display SC', fontSize: pt(10), color: col('accent'), width: mm(8) }, leader: { char: '. ', gap: mm(2.5) }, // A second line from the heading's to="…" attribute: whom each letter is written to. subtitle: { enabled: true, attr: 'to', fontSize: pt(9.5), color: col('muted') }, }; // #endregion // #region letter: the novel's opener: the northern lights, bled off the head of the recto const BAND = 104; // mm from the trim to the foot of the drawing, which fades into the paper // minHeight ends AIR mm below it and the first line takes the next grid line. to: 'bleed' is the // trim until page.cutLines adds a bleed; 3 mm drop the foot 1.8 mm, still above that line. const AIR = 2; const opener = design([ image('band', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })), label('to', '{attr.to}', 7.5, onPage(19), 'ice', 2), text('title', '{titleText}', 'Playfair Display SC', 46, onPage(25), { color: col('bone'), lineHeight: 1 }), text('dateline', '{attr.dateline}', 'Fanwood Text', 10.5, onPage(44), { italic: true, color: col('bone') }), // minHeight counts from the text block, TOP below the trim. The texts reserve their height, // the drawing nothing (gotcha: opener-image-no-reserve), so minHeight sets the first line. ], BAND - TOP + AIR); // Walton's letters come before Chapter I: they take the novel's opener but no number. const letter = { id: 'letter', numbered: false }; // #endregion // #region heads: running heads on the novel's body pages; the book on versos, letter on rectos const HEAD_Y = 12.5; // mm from the trim to the heads' line const HEAD_GAP = 8; // mm from the outer margin, where the folio hangs, to the words beside it const head = (id, content, parity, edge, x, extra = {}) => ({ ...label(id, content, 7.5, at(edge, x, HEAD_Y, { to: 'page' }), 'muted', 1.4), align: 'left', parity, pages: 'body', ...extra }); // pages: 'body' keeps them off the opener const numeral = { fontFamily: 'Playfair Display SC', fontSize: pt(8.5), fontWeight: 400, letterSpacing: pt(0.6), color: col('ink') }; // folios in the folio face, as on the openers const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, numeral), head('verso-title', '{title}', 'even', 'top-left', OUTER + HEAD_GAP), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + HEAD_GAP)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, numeral), ] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'en-us', // hyphenation patterns, by exact code (gotcha: hyphenation-locales) colorPalette, layout: { layoutType: 'single' }, 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'), pageNumbering, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true }, }, bodyText: { // justified, hyphenated, optimal line breaking, widow control: the defaults fontFamily: 'Fanwood Text', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // An even grey: spaces 0.75–1.75 of their width; a last line under 36 spaces' width (about // 18 letters: a lone 'occurrence.') is a runt, and costs three times the default. minWordSpacing: 0.75, maxWordSpacing: 1.75, runtMinCharacters: 36, runtPenalty: 3000, }, headings: { fontFamily: 'Playfair Display SC', fontWeight: 400, color: col('ink'), // Break restated (gotcha: headings-drop-h1-break). span 'page' paints the opener unclipped, // so the drawing reaches the trim; an in-column design is clipped to the text block. levels: [{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }], }, headingStyles: [...leaves, front, letter], toc: contents, paragraphStyles: prelimStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 105行 · content.en.md
title: "Frankenstein" subtitle: "or, The Modern Prometheus" author: "Mary Shelley" publishDate: "1818" --- # Cover {style="cover" publisher="Postext Classics"} # Half title {style="half"} # Frontispiece {style="plate" caption="We perceived a low carriage, fixed on a sledge and drawn by dogs, pass on towards the north, at the distance of half a mile." source="Letter IV"} # Title page {style="title" publisher="Postext Classics · MMXXVI"} # Imprint {style="imprint"} :::paragraphs{style="small-print"} *Frankenstein; or, The Modern Prometheus* was first published anonymously, in three volumes, by Lackington, Hughes, Harding, Mavor & Jones of Finsbury Square, London, on 1 January 1818. This edition follows that first text, transcribed by Project Gutenberg from a photographic reprint (eBook 41445), and keeps its spelling: phænomena, their’s, St. Petersburgh. Set in Fanwood Text, Playfair Display SC and Cinzel, all under the SIL Open Font License. Cover, frontispiece and publisher’s mark drawn in code; pages laid out in the browser by Postext. Text: public domain. Design and editorial matter: CC BY 4.0. Postext Cookbook · No. 6 ::: # Dedication {style="quiet"} :::paragraphs{style="dedication"} *To* William Godwin, *Author of Political Justice,* *Caleb Williams, &c.* These volumes *Are respectfully inscribed* By The Author. ::: :::paragraphs{style="verse"} *Did I request thee, Maker, from my clay* *To mould me man? Did I solicit thee* *From darkness to promote me?——* ::: :::paragraphs{style="source"} Paradise Lost ::: # Contents {style="front"} :::toc # Preface {style="front" toc="true"} The event on which this fiction is founded has been supposed, by Dr. Darwin, and some of the physiological writers of Germany, as not of impossible occurrence. I shall not be supposed as according the remotest degree of serious faith to such an imagination; yet, in assuming it as the basis of a work of fancy, I have not considered myself as merely weaving a series of supernatural terrors. The event on which the interest of the story depends is exempt from the disadvantages of a mere tale of spectres or enchantment. It was recommended by the novelty of the situations which it developes; and, however impossible as a physical fact, affords a point of view to the imagination for the delineating of human passions more comprehensive and commanding than any which the ordinary relations of existing events can yield. I have thus endeavoured to preserve the truth of the elementary principles of human nature, while I have not scrupled to innovate upon their combinations. The *Iliad*, the tragic poetry of Greece,—Shakespeare, in the *Tempest* and *Midsummer Night’s Dream*,—and most especially Milton, in *Paradise Lost*, conform to this rule; and the most humble novelist, who seeks to confer or receive amusement from his labours, may, without presumption, apply to prose fiction a licence, or rather a rule, from the adoption of which so many exquisite combinations of human feeling have resulted in the highest specimens of poetry. The circumstance on which my story rests was suggested in casual conversation. It was commenced, partly as a source of amusement, and partly as an expedient for exercising any untried resources of mind. Other motives were mingled with these, as the work proceeded. I am by no means indifferent to the manner in which whatever moral tendencies exist in the sentiments or characters it contains shall affect the reader; yet my chief concern in this respect has been limited to the avoiding of the enervating effects of the novels of the present day, and to the exhibitions of the amiableness of domestic affection, and the excellence of universal virtue. The opinions which naturally spring from the character and situation of the hero are by no means to be conceived as existing always in my own conviction; nor is any inference justly to be drawn from the following pages as prejudicing any philosophical doctrine of whatever kind. It is a subject also of additional interest to the author, that this story was begun in the majestic region where the scene is principally laid, and in society which cannot cease to be regretted. I passed the summer of 1816 in the environs of Geneva. The season was cold and rainy, and in the evenings we crowded around a blazing wood fire, and occasionally amused ourselves with some German stories of ghosts, which happened to fall into our hands. These tales excited in us a playful desire of imitation. Two other friends (a tale from the pen of one of whom would be far more acceptable to the public than any thing I can ever hope to produce) and myself agreed to write each a story, founded on some supernatural occurrence. The weather, however, suddenly became serene; and my two friends left me on a journey among the Alps, and lost, in the magnificent scenes which they present, all memory of their ghostly visions. The following tale is the only one which has been completed. :::pagebreak{parity="odd"} :::numbering{format="decimal" startAt=1} # Letter I {style="letter" to="To Mrs. Saville, England" dateline="St. Petersburgh, Dec. 11th, 17—"} You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings. I arrived here yesterday; and my first task is to assure my dear sister of my welfare, and increasing confidence in the success of my undertaking. I am already far north of London; and as I walk in the streets of Petersburgh, I feel a cold northern breeze play upon my cheeks, which braces my nerves, and fills me with delight. Do you understand this feeling? This breeze, which has travelled from the regions towards which I am advancing, gives me a foretaste of those icy climes. Inspirited by this wind of promise, my day dreams become more fervent and vivid. I try in vain to be persuaded that the pole is the seat of frost and desolation; it ever presents itself to my imagination as the region of beauty and delight. There, Margaret, the sun is for ever visible; its broad disk just skirting the horizon, and diffusing a perpetual splendour. There—for with your leave, my sister, I will put some trust in preceding navigators—there snow and frost are banished; and, sailing over a calm sea, we may be wafted to a land surpassing in wonders and in beauty every region hitherto discovered on the habitable globe. Its productions and features may be without example, as the phænomena of the heavenly bodies undoubtedly are in those undiscovered solitudes. What may not be expected in a country of eternal light? I may there discover the wondrous power which attracts the needle; and may regulate a thousand celestial observations, that require only this voyage to render their seeming eccentricities consistent for ever. I shall satiate my ardent curiosity with the sight of a part of the world never before visited, and may tread a land never before imprinted by the foot of man. These are my enticements, and they are sufficient to conquer all fear of danger or death, and to induce me to commence this laborious voyage with the joy a child feels when he embarks in a little boat, with his holiday mates, on an expedition of discovery up his native river. But, supposing all these conjectures to be false, you cannot contest the inestimable benefit which I shall confer on all mankind to the last generation, by discovering a passage near the pole to those countries, to reach which at present so many months are requisite; or by ascertaining the secret of the magnet, which, if at all possible, can only be effected by an undertaking such as mine. These reflections have dispelled the agitation with which I began my letter, and I feel my heart glow with an enthusiasm which elevates me to heaven; for nothing contributes so much to tranquillize the mind as a steady purpose,—a point on which the soul may fix its intellectual eye. This expedition has been the favourite dream of my early years. I have read with ardour the accounts of the various voyages which have been made in the prospect of arriving at the North Pacific Ocean through the seas which surround the pole. You may remember, that a history of all the voyages made for purposes of discovery composed the whole of our good uncle Thomas’s library. My education was neglected, yet I was passionately fond of reading. These volumes were my study day and night, and my familiarity with them increased that regret which I had felt, as a child, on learning that my father’s dying injunction had forbidden my uncle to allow me to embark in a sea-faring life. These visions faded when I perused, for the first time, those poets whose effusions entranced my soul, and lifted it to heaven. I also became a poet, and for one year lived in a Paradise of my own creation; I imagined that I also might obtain a niche in the temple where the names of Homer and Shakespeare are consecrated. You are well acquainted with my failure, and how heavily I bore the disappointment. But just at that time I inherited the fortune of my cousin, and my thoughts were turned into the channel of their earlier bent. Six years have passed since I resolved on my present undertaking. I can, even now, remember the hour from which I dedicated myself to this great enterprise. I commenced by inuring my body to hardship. I accompanied the whale-fishers on several expeditions to the North Sea; I voluntarily endured cold, famine, thirst, and want of sleep; I often worked harder than the common sailors during the day, and devoted my nights to the study of mathematics, the theory of medicine, and those branches of physical science from which a naval adventurer might derive the greatest practical advantage. Twice I actually hired myself as an under-mate in a Greenland whaler, and acquitted myself to admiration. I must own I felt a little proud, when my captain offered me the second dignity in the vessel, and entreated me to remain with the greatest earnestness; so valuable did he consider my services. And now, dear Margaret, do I not deserve to accomplish some great purpose. My life might have been passed in ease and luxury; but I preferred glory to every enticement that wealth placed in my path. Oh, that some encouraging voice would answer in the affirmative! My courage and my resolution is firm; but my hopes fluctuate, and my spirits are often depressed. I am about to proceed on a long and difficult voyage; the emergencies of which will demand all my fortitude: I am required not only to raise the spirits of others, but sometimes to sustain my own, when their’s are failing. This is the most favourable period for travelling in Russia. They fly quickly over the snow in their sledges; the motion is pleasant, and, in my opinion, far more agreeable than that of an English stage-coach. The cold is not excessive, if you are wrapt in furs, a dress which I have already adopted; for there is a great difference between walking the deck and remaining seated motionless for hours, when no exercise prevents the blood from actually freezing in your veins. I have no ambition to lose my life on the post-road between St. Petersburgh and Archangel. I shall depart for the latter town in a fortnight or three weeks; and my intention is to hire a ship there, which can easily be done by paying the insurance for the owner, and to engage as many sailors as I think necessary among those who are accustomed to the whale-fishing. I do not intend to sail until the month of June: and when shall I return? Ah, dear sister, how can I answer this question? If I succeed, many, many months, perhaps years, will pass before you and I may meet. If I fail, you will see me again soon, or never. Farewell, my dear, excellent, Margaret. Heaven shower down blessings on you, and save me, that I may again and again testify my gratitude for all your love and kindness. :::paragraphs{style="signature"} *Your affectionate brother,* R. Walton. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the publisher's mark and an ice crystal, drawn in the palette const f = (n) => n.toFixed(2); const poly = (pts, fill, a = 1) => `<path d="M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z"` + ` fill="${fill}" fill-opacity="${a}"/>`; 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; // each drawing is w × h mm in its viewBox and declares w·PX × h·PX pixels const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" ` + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${body}</svg>`; // The publisher's mark: a polar star in a double ring. The ornament: an ice crystal. const EMBLEM = 40; // mm: both are drawn on a 40 × 40 viewBox function mark() { const star = []; for (let i = 0; i < 16; i++) { const a = (i * Math.PI) / 8; const rr = i % 4 === 0 ? 13 : i % 2 === 0 ? 7 : 2.6; star.push([20 + rr * Math.sin(a), 20 - rr * Math.cos(a)]); } const rings = [[19, 'accent'], [18, 'paper'], [16.2, 'accent'], [15.6, 'paper']]; return svgOf(EMBLEM, EMBLEM, rings.map(([rr, c]) => disk(20, 20, rr, palette[c])).join('') + poly(star, palette.ink)); } function crystal() { const arm = poly([[19.5, 20], [19.5, 3], [20, 1.5], [20.5, 3], [20.5, 20]], palette.accent) + poly([[20, 9], [15, 5], [15.5, 4.4], [20, 7.8], [24.5, 4.4], [25, 5]], palette.accent); return svgOf(EMBLEM, EMBLEM, [0, 60, 120, 180, 240, 300].map((a) => `<g transform="rotate(${a} 20 20)">${arm}</g>`).join('') + disk(20, 20, 2.4, palette.accent)); } for (const [id, svg] of Object.entries({ mark: mark(), crystal: crystal() })) { await loadSvg(`${id}.svg`, svg); } // Each declares svgOf's pixel size. const svgResource = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', altText, createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } }); const emblems = [ svgResource('mark', EMBLEM, EMBLEM, 'Publisher’s mark: a polar star in a double ring.'), svgResource('crystal', EMBLEM, EMBLEM, 'Ornament: an ice crystal.'), ]; // #endregion const painting = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h } }); const paintings = [ // JPEGs in assets/; only the designs use them, nothing cites them painting('cover', 1000, 1535, 'Lightning strikes Mont Blanc over the lake of Geneva.'), painting('plate', 1000, 1299, 'A brig beset in pack ice, a sledge drawn by dogs far off.'), painting('band', 1400, 1129, 'Northern lights over the frozen Neva and St Petersburgh.'), ]; await Promise.all(paintings.map(({ bitmap: b }) => loadImage(b.fileId, asset(b.fileId)))); const resources = [...paintings, ...emblems]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded before the first build (gotcha: fonts-first). The PDF asks for a bold Fanwood and an // italic Cinzel too; the kit's provider snaps to shipped faces (gotcha: pdf-provider-all-styles). const FONTS = { 'Fanwood Text': ['400', '400i'], Cinzel: ['400', '600'], 'Playfair Display SC': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: lay out once (buildDocument settles the contents), show, offer a PDF await loadFonts(FONTS, markdown); // :::toc lists each heading with the label of the page it lands on; buildDocument lays the // document out again until those labels stop moving (ix for the Preface, 1 for Letter I). const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Frankenstein · the opening leaves' }); // The PDF gets /PageLabels from the same labels: a viewer numbers its pages i–x, then 1, 2. offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregion工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行
// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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上的食谱文件夹 ↗
变化
#前置各页用大写罗马数字
大写数字在页码字体里印成完整的大写字母,比小写数字得到的小型大写字母高。
-const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i
+const pageNumbering = { format: 'upper-roman', startAt: 1 }; // startAt spelled out: cover = I#全书连续编号
去掉startAt,计数就以阿拉伯数字接着往下编,于是Letter I成为第11页,和那些把前置部分与正文一起计页的书一样。
-:::numbering{format="decimal" startAt=1}
+:::numbering{format="decimal"}#去掉卷首插图
删除# Frontispiece标题,把书名页送到右页:第iv页保持空白,Preface仍是第ix页,Letter I仍是第1页。
- { id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute
+ { id: 'title', ...blind, breakBefore: { enabled: true, parity: 'odd' },
+ advancedDesign: design([ // v: four frontmatter fields, one attribute#为印刷加上出血和裁切线
用cutLines后,封面和章首页(两者都锚定在出血上)会延伸到成品线外3 mm;页码和页数不变(裁切线)。
backgroundColor: col('paper'), pageNumbering,
+ cutLines: { enabled: true }, // a 3 mm bleed and crop marks常见问题
易错点
:::numbering从下一页起生效
:::numbering从下一个开始的页面起改变计数,而不是当前页。把它紧跟在:::pagebreak之后(第一章之前用parity odd),新的编号才会从你想要的地方开始。 罗马数字页码的前置部分 →
易错点
标题样式会继承其级别的分页设置
headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →
易错点
什么都不打印的标题需要一个只含一个元素的设计
只用于结构的标题(版权页或献词)仍然会打印:通栏标题会得到自动生成的{titleText}章首页,栏内标题会打印它的文字。给它一个高级设计,里面只放一个看不见的元素,例如一个0.1 pt的框。 设计过的章首页 →
易错点
列表写'arabic',资源写'roman-upper',页码写'upper-roman'
每种编号设置对格式的拼写都不同:列表的numberFormat用'arabic'(写'decimal'会打印出"undefined"),资源类型的counterFormat用'roman-upper',页码和:::numbering用'upper-roman'。 编号列表 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
段落样式没有斜体颜色
在postext 1.4.1中,段落样式可以设置color和boldColor,但没有italicColor:其中的斜体文字取bodyText.italicColor。于是一个淡色样式(小字、出处行)里的斜体书名会比周围的文字颜色更深。这类样式要么沿用正文的墨色,要么不用斜体。 段落样式 →
易错点
不换行空格仍然会断行
在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →
易错点
PDF会请求每个字族的所有字重和样式
renderToPdf会向字体提供函数请求任何块可能用到的每个字族的粗体、斜体和粗斜体,哪怕从来没有印出来,只要有一次请求被拒绝,导出就会中止。提供函数必须就近匹配该字族实际提供的字重,没有斜体时退回正体。 嵌入PDF的字体 →
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
沙盒检查 · parityCascade
奇偶连锁空白页
原因. 奇偶换页层层叠加,连续产生了两页以上的空白页。
解决. 检查always-odd换页和breakBefore的奇偶设置;章较短时,parity设为any通常可以避免这些空白页。 文档 →
沙盒检查 · numberingInvalidFormat
编号格式无效
原因. 某个:::numbering的format不是decimal、lower-roman、upper-roman、lower-alpha或upper-alpha之一。
解决. 使用这五种页码格式之一;列表和资源的格式写法不同。 文档 →
- 这些页继承了一级标题的
'odd'换页。不处理的话,每一页都会等到右页才开始。前置部分就会从十页变成十四页,多出四个空白左页,卷首插图对着的也是空白页而不是书名页。像本食谱这样用parity: 'any'重新声明换页,或者用breakBefore: { enabled: false }关掉它;无论哪种,通栏标题都会另起一页。 - 当一级标题以引擎自己的H1默认值
'always-odd'换页时,去掉:::numbering前面的:::pagebreak。如果它之前的文字结束在右页,pagebreak会补到下一个右页,然后标题再加上它的分隔页并再补一次,结果是连续三个空白页而不是一个。像这里在'odd'换页之后,pagebreak不起作用;在'any'之后,正是它把第1页放到右页。 - Preface多出一页,会把Letter I往后推两页,因为奇数页换页会在它前面加一个空白左页。每次编辑前置部分后,都要检查页数。
- 章首页的文字要留在文字块底边以上。在Postext 1.4.1中,伸出文字块底边的标题设计会失去全部预留高度,包括
minHeight。封面上看不出变化,因为后面没有正文;在章首页上,正文会紧接着标题开始。 - 目录的前导点用页码的字体和颜色排,
toc.leader没有单独的颜色设置,所以这里印成钢蓝色。
致谢
- 文本
- Frankenstein; or, The Modern Prometheus (1818): the dedication, Letter I, a line of Letter IV (the frontispiece caption) and one of volume I, chapter VI (quoted in a code comment) · Mary Shelley · 公有领域
- The unsigned Preface of the 1818 edition (by common attribution) · Percy Bysshe Shelley · 公有领域
- Paradise Lost (1667), book X, lines 743–745: the epigraph of the 1818 title leaves · John Milton · 公有领域
- 图片
- The cover: lightning over Mont Blanc and the lake of Geneva · Generated With Diffusion Models · 原创
- The frontispiece: a brig beset in pack ice, a dog sledge far off · Generated With Diffusion Models · 原创
- Letter I’s opener: northern lights over the frozen Neva · Generated With Diffusion Models · 原创
- 字体
- Fanwood Text (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1) · Cinzel (SIL OFL 1.1)


