かんたんな説明
イタリアの子ども向け『ピノッキオの冒険』の最初の数ページです。各章は専用のやわらかな水彩の絵と、出来事を短くまとめた文で始まります。
できあがり
コッローディ『ピノッキオの冒険』(『Le avventure di Pinocchio』)のイタリアの子ども向け版の冒頭を、190 × 240 mmのページにAveria Serif Libreの13/19 ptで組みます。偶数ページの扉が第1章と向かい合います。扉にはトスカーナの丘の水彩画があり、ロバの荷車が大工の仕事場へ薪を運んでいて、その下にFredericka the Greatで組んだ書名が入ります。各章は紙に溶け込むように薄れていく水彩のヴィネットの下で始まり(第1章はしゃべる丸太の上に斧を振り上げたMaestro Ciliegia、第2章は黄色いかつらをかぶって丸太を受け取るGeppetto)、続いて章の行と、中央にそろえたイタリックのコッローディの梗概が入ります。4枚の絵はすべて、1つのスタイルのプロンプトとページのパレットから拡散モデルで生成しました。柱は章扉のない8ページと9ページにだけ印刷されます。ノンブルは木の色の円の中に置かれ、第1章はキャプションも番号もない2つのサクランボで閉じます。
このレシピが答える質問
- 章ごとに異なる写真、色、章扉のバリエーションを与えるには?
- 番号のないアートワーク(オーナメント、カット、ロゴ)を組むには?
- 章扉に著者名の行、リード文、ドロップキャップ付きの導入文を加えるには?
- 章扉や白ページで柱を隠したり、空白の偶数ページを部の色で塗ったりするには?
手短な答え
// # 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 };
材料
- 機能
- デザインした章扉ページデザインの画像柱とノンブル見出しスタイル見出しの属性奇数ページから始まる章ページデザインのテキスト・罫・ボックスページの役割ごとの柱独自のリソースの種類その場に置く図リソースとしての図と表ハイフネーションと文書の言語見開きの余白セマンティックカラーパレットキャンバス上のページ
- 種類
- 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 · 見出しスタイルは1つ、ヴィネットは見出しごとに指定
この手順のコードは手短な答えにあります。画像要素のresourceIdはデザインテキストと同じプレースホルダーを受け付けます。そのため'{attr.vignetta}'は、{attr.summary}が梗概を印刷するのと同じように、見出しが指定したリソースを描きます。2つの章はcapitoloスタイルを共有し、見出しの行で絵を指定します。# Capitolo II {style="capitolo" vignetta="polenta" summary="…"}です。3つ目の章に必要なのはリソースと見出しを1つずつ追加することで、スタイルを増やす必要はありません(画像要素)。章の行は#vignetteにアンカーせず、コンテナーの上端から位置を決め、ヴィネットの箱の6 mm下に置きます。画像は高さを確保しないので、その下にアンカーしたテキストは絵がないものとして計測されます。minHeightがなければ、第1章の本文はページ上端から62 mmの位置、つまり絵の中から始まってしまいます。minHeightを14行の下限として設定すると、第1章の本文は、梗概が1行長い第2章の本文と同じ行から始まります。設定しなければ、第1章の本文は6.7 mm高い位置から始まります(幅と詳細デザイン)。
#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はページを描くときに、各ファイルidでloadImage()が登録した絵をそのまま描きます。ヴィネットとサクランボは白地で生成し、紙の色#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の深さの風景、続いて属性から取る肩書き、書名、フロントマターから取る副題と著者です。この本は1段組みですが、スタイルには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ページ、つまり偶数ページから始めるので、扉が第1章と向かい合い、最初に印刷されるノンブルは7になります。pages: 'body'は扉と2つの章扉から柱を外すので、柱は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の行がある位置に、行長の6分の1の幅で中央にそろえて置くので、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'にすると、第2章は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では、段内にとどまる詳細デザインの見出しは段の上端でクリップされます。ページや裁ち落としに固定した箱や画像は左右の余白には描かれますが、天の余白には描かれず、警告もありません。こうした見出しには、1段組みの本でもspan: 'page'を与えてください。デザインがページの章扉の帯として、欠けずに描かれます。 デザインした章扉 →
つまずき
属性値には{も}も書けない。"を含む値は一重引用符で囲む
属性値は閉じ波かっこで終わるため、{や}を含められません。二重引用符を含む値は一重引用符で囲みます。ドル記号は問題ありません。 見出しの属性 →
つまずき
フロントマターの値はすべて引用符で囲む
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の横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
デザインのテキストのlineHeightは倍率で、寸法ではない
デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
- Markdownでは、ノーブレークスペース(U+00A0)がコッローディの会話のダッシュをそれぞれの語に結びつけているので、閉じのダッシュが行頭に来ることはありません。さらに2か所で、段落末の「di legno」と「in piedi」を分かれないようにしています。1.4.1では、段落に太字やイタリックがあると、ノーブレークスペースの位置でも改行されることがあります。結びつけを含む段落でイタリックの語があるのは、9ページの「ohi」を含む段落だけです。そこには4か所の結びつけがあり、キャプチャーではどれも行の途中か段落の末尾に来ています。
クレジット
- 本文
- 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)


