成品一览
把科洛迪《Le avventure di Pinocchio》一个意大利语童书版本的开头几页,用Averia Serif Libre以13/19 pt排在190 × 240 mm的页面上。左页的书名页与第一章相对:一幅托斯卡纳丘陵的水彩画,一辆驴车把柴火运往木匠作坊,下方是Fredericka the Great字体的书名。每章都在一幅渐隐入纸色的水彩题头画下开始(第一章是举斧对着会说话的木头的Maestro Ciliegia,第二章是戴黄色假发接过木头的Geppetto),接着是章序行和居中斜体的科洛迪提要。四幅图都是用扩散模型根据同一个风格提示词和页面调色板生成的。书眉只印在第8页和第9页,即没有章首页的两页。页码放在木色圆片中,两颗樱桃为第一章收尾,不带题注和编号。
这道食谱解答
- 怎样给每章不同的照片、颜色或章首页样式?
- 怎样排不编号的插图:饰图、小花饰、标志?
- 怎样给章首页加作者行、提要或带首字下沉的导语?
- 怎样在章首页和空白页上隐藏书眉,或者把空白左页涂成篇的颜色?
简短回答
// # Capitolo I {style="capitolo" vignetta="ceppo" summary="Come andò che Maestro Ciliegia…"}
// resourceId takes placeholders like a design text: '{attr.vignetta}' draws the resource the
// heading names, so every chapter shares one style.
const VIGNETTE = { width: 84, height: 56 }; // mm, centred at the head of the text block
const SUMMARY = 124; // mm: the summary's measure, narrower than the text's
const opener = { enabled: true,
// A floor for the height the opener reserves, in whole lines, so the text under chapter I's
// two-line summary starts on the same grid line as the text under chapter II's three lines.
minHeight: pt(14 * LEAD),
slot: { elements: [
{ kind: 'image', id: 'vignette', resourceId: '{attr.vignetta}',
placement: at('container', 'top', 0, 0,
{ width: mm(VIGNETTE.width), height: mm(VIGNETTE.height) }) },
// Placed from the container, not from #vignette: images do not count in the height an
// opener reserves (gotcha: opener-image-no-reserve), the texts under them do.
text('chapter', '{titleText}', DISPLAY, 24, at('container', 'top-left', trail(2.4),
VIGNETTE.height + 6, { width: mm(MEASURE) }), { ...caps(2.4), lineHeight: 1 }),
text('summary', '{attr.summary}', TEXT, 12.5, at('#chapter', 'below',
(MEASURE - SUMMARY) / 2 - trail(2.4), 5, { width: mm(SUMMARY) }),
{ italic: true, lineHeight: 1.38 }), // a multiple (gotcha: design-lineheight-multiple)
] },
};
const chapter = { id: 'capitolo', advancedDesign: opener };
用料
- 功能
- 设计过的章首页页面设计中的图片书眉与页码标题样式标题属性从右页开始的章页面设计中的文字、线条和框按页面角色显示书眉自定义资源类型图就放在这里作为资源的图和表断词与文档语言对称页边距语义调色板在Canvas上绘制页面
- 类型
- Averia Serif Libre, Fredericka the Great, Quicksand(SIL OFL 1.1)
- 素材
ceppo-1200.jpgciliegie-600.jpgfrontespizio-1400.jpgpolenta-1200.jpg- The frontispiece: Tuscan hills, a hill village and a donkey cart of firewood on its way to the carpenter’s workshop (Generated With Diffusion Models, 原创)
- Chapter I’s vignette: Maestro Ciliegia, axe raised over the talking log (Generated With Diffusion Models, 原创)
- Chapter II’s vignette: Geppetto in his yellow wig receives the log, beside a pot of polenta (Generated With Diffusion Models, 原创)
- The cherry tailpiece (Generated With Diffusion Models, 原创)
做法
#1 · 一个标题样式,每个标题指定一幅题头画
这一步的代码见简短回答。图片元素的resourceId接受与设计文字相同的占位符,所以'{attr.vignetta}'绘制标题指定的资源,就像{attr.summary}印出它的提要一样。两章共用capitolo样式,并在标题行中指定各自的图:# Capitolo II {style="capitolo" vignetta="polenta" summary="…"}。再加一章只需多一个资源和一个标题,不用再建样式(图片元素)。章序行从容器顶部定位,位于题头画框下方6 mm处,而不是锚定在#vignette上。图片不预留高度,所以锚定在它下方的文字会按图片不存在来计算位置;这样一来,如果没有minHeight,第一章的正文会从页面顶部往下62 mm处开始,落进图画里。用minHeight设一个14行的下限后,第一章正文与第二章正文从同一行开始,尽管第二章的提要多一行;没有它时,第一章正文会高出6.7 mm(span与高级版面设计)。
#2 · 把画作作为位图资源载入
const PICTURES = { // id: [file, width, height, alt text in the book's language]
frontespizio: ['frontespizio-1400.jpg', 1400, 1105, 'Colline toscane con cipressi e un borgo; '
+ 'un contadino guida un asinello con un carretto di legna verso la bottega di un falegname.'],
ceppo: ['ceppo-1200.jpg', 1200, 800, 'Maestro Ciliegia, con la scure alzata sopra un pezzo di '
+ 'legno sul banco, si guarda intorno spaventato.'],
polenta: ['polenta-1200.jpg', 1200, 800, 'Geppetto, con la parrucca gialla, riceve il pezzo di '
+ 'legno da Maestro Ciliegia; accanto, un paiolo di polenta fumante.'],
ciliegie: ['ciliegie-600.jpg', 600, 626, 'Due ciliegie rosse su un picciolo, con una foglia.'],
};
const resources = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({
id, typeId: 'fregio', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText,
bitmap: { fileId, format: 'jpeg', width: w, height: h } }));
每幅图都是食谱assets/文件夹中的一个JPEG,按像素尺寸声明:版面只读取这些尺寸,Canvas绘制页面时,画出loadImage()在各个文件id下登记的图像。题头画和樱桃都在白底上生成,再与纸色#fffaf0正片叠底,所以它们的留白印出来就是页面本身,图片没有可见的边缘。卷首插图裁成190:150,即其区域的比例,因此适配后的图像能填满区域,不留纸色条带。风景画和题头画由版面设计中的图片元素绘制,樱桃则由Markdown中的::resource{id="ciliegie"}绘制。
#3 · 把书名页做成一个标题样式
const SKY = 150; // mm from the top edge: the foot of the painted field
// # Pinocchio {style="frontespizio" kicker="Le avventure di"}; subtitle and author come from
// the frontmatter. Every element hangs from the page, so the field runs off three edges.
const frontispiece = {
id: 'frontespizio',
span: 'page', // or the field is cut at the top of the text block (gotcha: opener-clipped-at-top)
footer: { elements: [] }, // no folio; the running heads already skip openers
advancedDesign: { enabled: true, slot: { elements: [ // array order is paint order
{ kind: 'image', id: 'landscape', resourceId: 'frontespizio', // cut to the field's 190:150
placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) },
text('kicker', '{attr.kicker}', LABEL, 11, at('page', 'top', trail(2.6), SKY + 11),
{ ...caps(2.6), fontWeight: 700 }),
text('title', '{titleText}', DISPLAY, 70, at('page', 'top', 0, SKY + 17),
{ lineHeight: 1, color: col('bark') }),
text('subtitle', '{subtitle}', TEXT, 15, at('page', 'top', 0, SKY + 44), { italic: true }),
text('author', '{author}', LABEL, 9, at('page', 'top', trail(2), SKY + 58),
{ ...caps(2), fontWeight: 600 }),
] } },
};
书名页是Markdown的第一个标题# Pinocchio {style="frontespizio" kicker="Le avventure di"},它的样式绘出页面上的一切:从上边缘起150 mm高的风景画,然后是属性中的眉题、书名,以及frontmatter中的副标题和作者。这本书只有一栏,样式仍设了span: 'page',因为留在栏内的版面设计会在版心顶部被裁掉,画作上方22 mm会印成白纸。空的footer去掉了章首页印的页码圆片,而pages: 'body'已经让书眉不出现在这一页(标题样式)。
#4 · 正文页上的书眉,圆片中的页码
// The excerpt opens on book page 6, a verso, so the title page faces chapter I.
const continuation = { pageIndexOffset: 5, pageNumbering: { startAt: 6 } };
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre sits off the page's centre
const head = (id, content, parity, x) => text(id, content, LABEL, 8,
at('page', 'top', x + trail(1.8), 12), { ...caps(1.8), fontWeight: 600, parity, pages: 'body' });
const DISC = 7.4; // mm: the folio's disc
// A fixed square with a radius of half its side is a circle; the figures sit in its middle.
// lineHeight 1.17: the baseline falls at 0.8 of the line, 0.94 em down, so Quicksand's
// figures (0.70 em tall) leave 0.23 em above and below them, and 'middle' centres that line.
const disc = (id, parity, pages, edge, x) => text(id, '{pageNumber}', LABEL, 8.5,
at('page', edge, x, -(BOTTOM - DISC) / 2, { width: mm(DISC), height: mm(DISC) }),
{ fontWeight: 700, color: col('paper'), lineHeight: 1.17, verticalAlign: 'middle', parity,
pages, box: { backgroundColor: col('wood'), borderRadius: mm(DISC / 2) } });
const header = { elements: [
head('verso-title', '{title}', 'even', -SHIFT), // the book on the left-hand page
head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // the chapter on the right
] };
const footer = { elements: [
disc('verso-folio', 'even', 'body', 'bottom-left', OUTER),
disc('recto-folio', 'odd', 'body', 'bottom-right', -OUTER),
disc('opener-folio', 'all', 'opener', 'bottom', 0), // a chapter opens on either side
] };
continuation从书中第6页(一个左页)开始给节选编页码,所以书名页与第一章相对,印出的第一个页码是7。pages: 'body'让书眉不出现在书名页和两个章首页上,所以书眉只印在第8页和第9页,左页印书名,右页印章名(文字元素)。页码是一个放在7.4 mm固定方框里的设计文字,圆角半径为边长的一半,于是成了一个圆;verticalAlign: 'middle'加上注释中算出的行距,让数字在圆中居中。纸色数字与木色的对比度为4.98:1。trail()把每个加大字距的标签向右移动半个字距,因为1.4.1在居中加大字距的行时,把最后一个字母后的那份字距也算了进去。
#5 · 用不编号的章尾饰图收束一章
// No caption prefix and no caption, so the cherries that close chapter I print with no
// label or number. The pictures the design slots paint are of this type too.
const resourceTypes = [{ id: 'fregio', name: 'Fregio', namePlural: 'Fregi', shortLabel: 'Fregio',
captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here', width: 0.16, align: 'center' } }];
资源类型会在题注前印出它的captionPrefix和编号。前缀为空、又没有题注时,樱桃下面什么也不印;设captionPrefix: 'Fregio'时,同一个资源会在图下印出Fregio 1.,高度增加10 mm(资源类型)。该类型的defaultPlacement把每个资源放在其::resource行所在的位置,宽为行长的六分之一并居中,所以Markdown只需在章的最后一段后写::resource{id="ciliegie"}。其他图片也属于这个类型,但没有任何:ref或::resource指向它们,所以它们既不会被放置,也不会被计数。
完整食谱
// ═══ Postext Cookbook · Nº 066 · Storybook chapters with vignettes and summaries ═══ // https://postext.dev/en/cookbook/storybook-chapter-vignettes // Code: MIT · Text: C. Collodi, 1883 (PD, Gutenberg #52484) · Pictures: diffusion models // Fonts: Averia Serif Libre, Fredericka the Great, Quicksand (SIL OFL 1.1) · Needs postext ≥ 1.8.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document (the sample is Italian in both) const RECIPE = 'storybook-chapter-vignettes'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config comes from here; the pictures were painted to it ink: '#2b2118', // text: a brown near-black paper: '#fffaf0', // a warm book paper, which the vignettes' margins were tinted to bark: '#6b4224', // chapter lines, running heads, the title wood: '#9a5f30', // the folio discs }; // Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The defaults linked to main-color, bold among them, print in bark instead of blue. { id: 'main-color', name: 'bark (defaults)', value: { hex: palette.bark, model: 'hex' } }, ]; // Text and summaries; the book's title and the chapter lines; running heads and folios. const [TEXT, DISPLAY, LABEL] = ['Averia Serif Libre', 'Fredericka the Great', 'Quicksand']; const TRIM = { width: 190, height: 240 }; // mm const [TOP, INNER, OUTER] = [22, 22, 20]; // mm; mirrored, so the spine margin swaps sides const MEASURE = TRIM.width - INNER - OUTER; // 148 mm const [BODY, LEAD, LINES] = [13, 19, 29]; // pt, pt, and whole lines in the text block const BOTTOM = TRIM.height - TOP - (LINES * LEAD * 25.4) / 72; // mm: 23.6 // Design-slot shorthands: a placement from an anchor's edge (mm), and a design text. const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id, content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', // wrap, never '…' (gotcha: overflow-ellipsis-default) placement, ...extra }); const caps = (tracking) => ({ textTransform: 'uppercase', letterSpacing: pt(tracking), color: col('bark') }); // Half a tracking (mm): 1.4.1 tracks after a centred line's last letter, shifting it left. const trail = (tracking) => (tracking * 25.4) / 72 / 2; // #region answer: one opener for every chapter: the vignette the heading names, then its summary // # Capitolo I {style="capitolo" vignetta="ceppo" summary="Come andò che Maestro Ciliegia…"} // resourceId takes placeholders like a design text: '{attr.vignetta}' draws the resource the // heading names, so every chapter shares one style. const VIGNETTE = { width: 84, height: 56 }; // mm, centred at the head of the text block const SUMMARY = 124; // mm: the summary's measure, narrower than the text's const opener = { enabled: true, // A floor for the height the opener reserves, in whole lines, so the text under chapter I's // two-line summary starts on the same grid line as the text under chapter II's three lines. minHeight: pt(14 * LEAD), slot: { elements: [ { kind: 'image', id: 'vignette', resourceId: '{attr.vignetta}', placement: at('container', 'top', 0, 0, { width: mm(VIGNETTE.width), height: mm(VIGNETTE.height) }) }, // Placed from the container, not from #vignette: images do not count in the height an // opener reserves (gotcha: opener-image-no-reserve), the texts under them do. text('chapter', '{titleText}', DISPLAY, 24, at('container', 'top-left', trail(2.4), VIGNETTE.height + 6, { width: mm(MEASURE) }), { ...caps(2.4), lineHeight: 1 }), text('summary', '{attr.summary}', TEXT, 12.5, at('#chapter', 'below', (MEASURE - SUMMARY) / 2 - trail(2.4), 5, { width: mm(SUMMARY) }), { italic: true, lineHeight: 1.38 }), // a multiple (gotcha: design-lineheight-multiple) ] }, }; const chapter = { id: 'capitolo', advancedDesign: opener }; // #endregion // #region frontispiece: the title page as a heading style: a painted field, then the title const SKY = 150; // mm from the top edge: the foot of the painted field // # Pinocchio {style="frontespizio" kicker="Le avventure di"}; subtitle and author come from // the frontmatter. Every element hangs from the page, so the field runs off three edges. const frontispiece = { id: 'frontespizio', span: 'page', // or the field is cut at the top of the text block (gotcha: opener-clipped-at-top) footer: { elements: [] }, // no folio; the running heads already skip openers advancedDesign: { enabled: true, slot: { elements: [ // array order is paint order { kind: 'image', id: 'landscape', resourceId: 'frontespizio', // cut to the field's 190:150 placement: at('page', 'top-left', 0, 0, { width: 'fill', height: mm(SKY) }) }, text('kicker', '{attr.kicker}', LABEL, 11, at('page', 'top', trail(2.6), SKY + 11), { ...caps(2.6), fontWeight: 700 }), text('title', '{titleText}', DISPLAY, 70, at('page', 'top', 0, SKY + 17), { lineHeight: 1, color: col('bark') }), text('subtitle', '{subtitle}', TEXT, 15, at('page', 'top', 0, SKY + 44), { italic: true }), text('author', '{author}', LABEL, 9, at('page', 'top', trail(2), SKY + 58), { ...caps(2), fontWeight: 600 }), ] } }, }; // #endregion // #region folios: running heads on body pages only, folios in wood-coloured discs // The excerpt opens on book page 6, a verso, so the title page faces chapter I. const continuation = { pageIndexOffset: 5, pageNumbering: { startAt: 6 } }; const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre sits off the page's centre const head = (id, content, parity, x) => text(id, content, LABEL, 8, at('page', 'top', x + trail(1.8), 12), { ...caps(1.8), fontWeight: 600, parity, pages: 'body' }); const DISC = 7.4; // mm: the folio's disc // A fixed square with a radius of half its side is a circle; the figures sit in its middle. // lineHeight 1.17: the baseline falls at 0.8 of the line, 0.94 em down, so Quicksand's // figures (0.70 em tall) leave 0.23 em above and below them, and 'middle' centres that line. const disc = (id, parity, pages, edge, x) => text(id, '{pageNumber}', LABEL, 8.5, at('page', edge, x, -(BOTTOM - DISC) / 2, { width: mm(DISC), height: mm(DISC) }), { fontWeight: 700, color: col('paper'), lineHeight: 1.17, verticalAlign: 'middle', parity, pages, box: { backgroundColor: col('wood'), borderRadius: mm(DISC / 2) } }); const header = { elements: [ head('verso-title', '{title}', 'even', -SHIFT), // the book on the left-hand page head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // the chapter on the right ] }; const footer = { elements: [ disc('verso-folio', 'even', 'body', 'bottom-left', OUTER), disc('recto-folio', 'odd', 'body', 'bottom-right', -OUTER), disc('opener-folio', 'all', 'opener', 'bottom', 0), // a chapter opens on either side ] }; // #endregion // #region tailpiece: an unnumbered resource type, set where ::resource stands // No caption prefix and no caption, so the cherries that close chapter I print with no // label or number. The pictures the design slots paint are of this type too. const resourceTypes = [{ id: 'fregio', name: 'Fregio', namePlural: 'Fregi', shortLabel: 'Fregio', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here', width: 0.16, align: 'center' } }]; // #endregion // #region pictures: four paintings, declared at their pixel size under the file ids they load as const PICTURES = { // id: [file, width, height, alt text in the book's language] frontespizio: ['frontespizio-1400.jpg', 1400, 1105, 'Colline toscane con cipressi e un borgo; ' + 'un contadino guida un asinello con un carretto di legna verso la bottega di un falegname.'], ceppo: ['ceppo-1200.jpg', 1200, 800, 'Maestro Ciliegia, con la scure alzata sopra un pezzo di ' + 'legno sul banco, si guarda intorno spaventato.'], polenta: ['polenta-1200.jpg', 1200, 800, 'Geppetto, con la parrucca gialla, riceve il pezzo di ' + 'legno da Maestro Ciliegia; accanto, un paiolo di polenta fumante.'], ciliegie: ['ciliegie-600.jpg', 600, 626, 'Due ciliegie rosse su un picciolo, con una foglia.'], }; const resources = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({ id, typeId: 'fregio', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText, bitmap: { fileId, format: 'jpeg', width: w, height: h } })); // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'it', // Italian hyphenation, by its exact code (gotcha: hyphenation-locales) resourceTypes, colorPalette, header, footer, layout: { layoutType: 'single' }, page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), // *ohi* in ink, not in the defaults' bark // The book cites nothing, but a :ref added later prints in ink: main-color does not reach // this colour, which stays #295AA3 (gotcha: palette-skips-designs). referenceColor: col('ink'), firstLineIndent: mm(5), indentAfterHeading: false, maxWordSpacing: 1.45, // the default 2 lets 14 lines stretch past 1.45×; 3 still do, to 1.58× }, headings: { fontFamily: DISPLAY, fontWeight: 400, // the designs paint the titles: no Open Sans to load // Any headings object drops the H1 break (gotcha: headings-drop-h1-break): every // chapter on a new page, on either side, so chapter II opens on a verso. levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }], }, headingStyles: [frontispiece, chapter], paragraphStyles: [{ id: 'colofon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11), color: col('bark'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 74行 · content.en.md
title: "Le avventure di Pinocchio" subtitle: "Storia di un burattino" author: "Carlo Collodi" --- # Pinocchio {style="frontespizio" kicker="Le avventure di"} # Capitolo I {style="capitolo" vignetta="ceppo" summary="Come andò che Maestro Ciliegia, falegname, trovò un pezzo di legno che piangeva e rideva come un bambino."} — C’era una volta… — Un re! — diranno subito i miei piccoli lettori. — No, ragazzi, avete sbagliato. C’era una volta un pezzo di legno. Non era un legno di lusso, ma un semplice pezzo da catasta, di quelli che d’inverno si mettono nelle stufe e nei caminetti per accendere il fuoco e per riscaldare le stanze. Non so come andasse, ma il fatto gli è che un bel giorno questo pezzo di legno capitò nella bottega di un vecchio falegname, il quale aveva nome mastr’Antonio, se non che tutti lo chiamavano maestro Ciliegia, per via della punta del suo naso, che era sempre lustra e paonazza, come una ciliegia matura. Appena maestro Ciliegia ebbe visto quel pezzo di legno, si rallegrò tutto; e dandosi una fregatina di mani per la contentezza, borbottò a mezza voce: — Questo legno è capitato a tempo; voglio servirmene per fare una gamba di tavolino. — Detto fatto, prese subito l’ascia arrotata per cominciare a levargli la scorza e a digrossarlo; ma quando fu lì per lasciare andare la prima asciata, rimase col braccio sospeso in aria, perchè sentì una vocina sottile sottile, che disse raccomandandosi: — Non mi picchiar tanto forte! — Figuratevi come rimase quel buon vecchio di maestro Ciliegia! Girò gli occhi smarriti intorno alla stanza per vedere di dove mai poteva essere uscita quella vocina, e non vide nessuno! Guardò sotto il banco, e nessuno: guardò dentro un armadio che stava sempre chiuso, e nessuno; guardò nel corbello dei trucioli e della segatura, e nessuno; aprì l’uscio di bottega per dare un’occhiata anche sulla strada, e nessuno. O dunque?… — Ho capito; — disse allora ridendo e grattandosi la parrucca — si vede che quella vocina me la son figurata io. Rimettiamoci a lavorare. — E ripresa l’ascia in mano, tirò giù un solennissimo colpo sul pezzo di legno. — Ohi! tu m’hai fatto male! — gridò rammaricandosi la solita vocina. Questa volta maestro Ciliegia restò di stucco, cogli occhi fuori del capo per la paura, colla bocca spalancata e colla lingua giù ciondoloni fino al mento, come un mascherone da fontana. Appena riebbe l’uso della parola, cominciò a dire tremando e balbettando dallo spavento: — Ma di dove sarà uscita questa vocina che ha detto ohi?… Eppure qui non c’è anima viva. Che sia per caso questo pezzo di legno che abbia imparato a piangere e a lamentarsi come un bambino? Io non lo posso credere. Questo legno eccolo qui; è un pezzo di legno da caminetto, come tutti gli altri, e a buttarlo sul fuoco, c’è da far bollire una pentola di fagioli… O dunque? Che ci sia nascosto dentro qualcuno? Se c’è nascosto qualcuno, tanto peggio per lui. Ora l’accomodo io! — E così dicendo, agguantò con tutt’e due le mani quel povero pezzo di legno, e si pose a sbatacchiarlo senza carità contro le pareti della stanza. Poi si messe in ascolto, per sentire se c’era qualche vocina che si lamentasse. Aspettò due minuti, e nulla; cinque minuti, e nulla; dieci minuti, e nulla! — Ho capito — disse allora sforzandosi di ridere e arruffandosi la parrucca — si vede che quella vocina che ha detto *ohi*, me la son figurata io! Rimettiamoci a lavorare. — E perchè gli era entrato addosso una gran paura, si provò a canterellare per farsi un po’ di coraggio. Intanto, posata da una parte l’ascia, prese in mano la pialla, per piallare e tirare a pulimento il pezzo di legno; ma nel mentre che lo piallava in su e in giù, sentì la solita vocina che gli disse ridendo: — Smetti! tu mi fai il pizzicorino sul corpo! — Questa volta il povero maestro Ciliegia cadde giù come fulminato. Quando riaprì gli occhi, si trovò seduto per terra. Il suo viso pareva trasfigurito, e perfino la punta del naso, di paonazza come era quasi sempre, gli era diventata turchina dalla gran paura. ::resource{id="ciliegie"} # Capitolo II {style="capitolo" vignetta="polenta" summary="Maestro Ciliegia regala il pezzo di legno al suo amico Geppetto, il quale lo prende per fabbricarsi un burattino maraviglioso, che sappia ballare, tirar di scherma e fare i salti mortali."} In quel punto fu bussato alla porta. — Passate pure, — disse il falegname, senza aver la forza di rizzarsi in piedi. Allora entrò in bottega un vecchietto tutto arzillo, il quale aveva nome Geppetto; ma i ragazzi del vicinato, quando lo volevano far montare su tutte le furie, lo chiamavano col soprannome di Polendina, a motivo della sua parrucca gialla, che somigliava moltissimo alla polendina di granturco. Geppetto era bizzosissimo. Guai a chiamarlo Polendina! Diventava subito una bestia, e non c’era più verso di tenerlo. :::paragraphs{style="colofon"} Composto in Averia Serif Libre, Fredericka the Great e Quicksand (SIL Open Font License). Testo di Carlo Collodi (1883) secondo l’edizione Bemporad del 1902, Project Gutenberg n. 52484. Illustrazioni generate con modelli di diffusione. :::`; // content.<lang>.md: Collodi's Italian in both editions // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Averia Serif Libre': ['400', '400i'], 'Fredericka the Great': ['400'], Quicksand: ['400', '600', '700'] }; // every face, loaded before layout (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // A design slot or ::resource names a resource, the resource a file id: load each file. await Promise.all(Object.values(PICTURES).map(([file]) => loadImage(file, asset(file)))); const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Le avventure di Pinocchio' });工具包 · 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上的食谱文件夹 ↗
变化
#每章从右页开始
设为'odd'时,第二章移到第11页,第10页留空;书名页要关闭自己的分页,因为它继承了这一级的设置,否则会在空白的第6页之后移到第7页。
@@ the frontispiece style @@
id: 'frontespizio',
+ breakBefore: { enabled: false },
@@ headings, in config() @@
- levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }],
+ levels: [{ level: 1, breakBefore: { enabled: true, parity: 'odd' } }],常见问题
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
易错点
留在栏内的章首页在版心顶部被裁切
在postext 1.4.1中,留在栏内的高级设计标题会在栏的上边缘被裁切:锚定在页面或出血上的框或图片可以画进左右页边距,却画不进上页边距,而且没有任何警告。给这样的标题设span: 'page',即使是单栏的书也一样:这样它的设计会作为页面的章首页区段完整地绘制出来。 设计过的章首页 →
易错点
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把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 在Markdown中,每个科洛迪对白破折号都用一个不换行空格(U+00A0)与其后的词连在一起,所以收尾的破折号不会出现在行首;另外两个不换行空格让di legno和in piedi在各自段落末尾保持在一起。在1.4.1中,段落含粗体或斜体时,行仍可能在不换行空格处断开。唯一一个含斜体词又用了不换行空格的段落,即第9页含ohi的那段,有四处连接,在截图中每处都落在行中或段末。
致谢
- 文本
- Le avventure di Pinocchio (1883): chapter I and the opening of chapter II with Collodi’s chapter summaries, from the 1902 Bemporad edition, with typographic apostrophes, dashes and ellipses; chapter I’s summary has the comma after “falegname” that the edition’s table of contents prints and its chapter head leaves out · Carlo Collodi · 公有领域
- 图片
- The frontispiece: Tuscan hills, a hill village and a donkey cart of firewood on its way to the carpenter’s workshop · Generated With Diffusion Models · 原创
- Chapter I’s vignette: Maestro Ciliegia, axe raised over the talking log · Generated With Diffusion Models · 原创
- Chapter II’s vignette: Geppetto in his yellow wig receives the log, beside a pot of polenta · Generated With Diffusion Models · 原创
- The cherry tailpiece · Generated With Diffusion Models · 原创
- 字体
- Averia Serif Libre (SIL OFL 1.1) · Fredericka the Great (SIL OFL 1.1) · Quicksand (SIL OFL 1.1)


