本文へスキップ
レシピ番号25

レシピ集 · 第10章 · 出力と組み込み

同じフォントを埋め込んだ本物のPDF

PDFに書き出すリサイタルのプログラム。各書体はFontFace用とPDF用を兼ねて1回だけ取得し、見出しはそれぞれしおりになります。

このページの内容
出力
Canvas · PDF
レベル
中級
Postext
Postext 1.4.1で検証済み
1.4.1以上が必要 · postext-pdf ≥ 1.4.1
ライセンス
2026年9月25日更新
コードMIT · 本文CC BY 4.0

1ページ(全4ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり148 × 210 mm
  • 1段
  • Crimson Text 10/13.3
  • Fraunces
  • Tenor Sans
  • 4ページ
  • レベル
  • Postext 1.4.1
  • 組版時間39 ms
  • コード180行

かんたんな説明

夕べの演奏会の4ページのプログラム。画面とぴったり同じPDFとして保存する方法を示します。フォントはファイルの中に収まり、見出しは一覧からすぐに移動できます。

できあがり

夕暮れのリサイタル『Home from Sea』のプログラムです。ソプラノ、チェロ、ハープの小編成のアンサンブルのためのA5判4ページで、表紙は夜の青緑、題は金色のFrauncesイタリックで、金色の波模様の鱗が下端から何列も立ち上がります。中面では、演奏順を表にします。見出し行は青緑の地にTenor Sansの大文字で、合計行には色を敷きます。曲目解説は両端そろえのCrimson Textです。3つの歌の歌詞はLongfellow、Stevenson、Tennysonの詩で、刊本どおりのインデントで組みます。「Build the PDF」ボタンで作るのは、聴衆にメールで送ったり会場のWebサイトに載せたりするファイルです。4ページは画面と1行ずつ一致します。ブラウザーが読み込んだ7つのフォントファイルだけを埋め込み、見出しはそれぞれしおりになります。商業印刷に入稿するなら入稿用PDFを参照してください。

このレシピが答える質問

  • フォントを埋め込んだ本物のPDFをブラウザーで書き出すには?
  • 行の分割が変わったりPDFで単語が重なったりするのはなぜですか?フォントを正しく読み込むには?

手短な答え

script.js · 15–51行コード全体で見る
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}

材料

種類
Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1)
素材
  • The seigaiha waves on the cover, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)

作り方

#1 · 書体ごとに1回のダウンロードを、ページとPDFで共有する

コードは上の手短な答えにあります。Postextは組版の時点でブラウザーが読み込んでいる書体ですべての語を計測し、PDFは各行をそのレイアウトが置いた位置に描きます。PDFが別のファイル、たとえば可変フォントの既定のインスタンスやシステムの代替フォントを埋め込むと、各語は計測した位置から始まるものの、文字の幅が違うので、語が重なったり隙間が空いたりします。そこでこのレシピは各書体を1回だけ取得します。そのバイト列は最初の組版の前にFontFaceになり、フォントプロバイダーは同じバイト列をdecompressWoff2に通してPDFに渡します。書き出しのために別途ダウンロードするフォントはありません。

#2 · フォントが先、レイアウトは後

script.js · 363–367行コード全体で見る
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });

registerFaces()はFONTSのすべての書体が読み込まれたときにだけ解決するので、最初の組版はPDFが埋め込むのと同じ書体で計測します。これが唯一の組版でもあります。FONTSにはCrimson TextやFrauncesのブロックが求めうる太字とイタリックをすべて並べてあるので、キットのbuildWithFontsは追加するものを見つけないからです(計測キャッシュ)。ただし、この確認が助けになるのは画面だけです。FONTSに書体が欠けていると、buildWithFontsはそれを読み込んでレイアウトをもう一度走らせます。ブロックがその書体で組まれている場合はコンソールに警告を出しますが、太字やイタリックの場合は何も出しません。それでもPDFには、プロバイダーが持つ最も近い書体が入ります。

#3 · 書き出しと、埋め込んだものの一覧

script.js · 371–386行コード全体で見る
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
  document.getElementById('pt-actions').prepend(bar);
  return renderToPdf(doc, {
    fontProvider, // the answer: the page's own font files
    resourceBytes: imageBytes, // the cover drawing, as vector paths
    outlines: true, // the default, spelled out: each heading becomes a bookmark
    onProgress: ({ phase, pages, totalPages }) => {
      bar.value = pages / totalPages;
      const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
        save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
      kitStatus(`PDF · ${says[phase]}`);
    },
  });
}, `${RECIPE}.pdf`);

renderToPdfはonProgressを3つの段階で呼びます。フォントと表紙を埋め込んだ後のprepare、各ページの後のpages、ファイルを書く前のsaveです。ここではプロバイダーに10の書体を求めます。状態の行は、プロバイダーが渡した7つのファイル(FONTSにあるものとちょうど同じ)と、3つの代用を列挙します。代用はどれもTenor Sansのものです。この書体にはレギュラー400しかなく、ここでは太字やイタリックで組むことはありません。それ以外の代用があれば、FONTSに欠けている書体があるということです。Crimson Text 600を外すと、状態の行はCrimson Text 600 → 400と報告し、4ページ目の太字の「The Harbour Consort」はレギュラーのウェイトで印刷されます。CodePenのプレビューはPDFを表示できないので、キットのofferPdfがボタンを2つのリンクに置き換えます。1つは新しいタブでPDFを開き、もう1つはダウンロードします(PDFの生成)。

#4 · 見出しの木がしおりの木になる

script.js · 108–116行コード全体で見る
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };

PDFのしおりパネルには、表紙の題、その下に「About the music」を持つ「Programme」、3つの歌を持つ「The texts」、「The performers」が並びます。アウトラインは見出しのレベルに従うので、レベルはしおりを念頭に置いて選びます。ここでは節ごとにH1、歌ごとにH2です。「The performers」には独自の見出しスタイルがあり、改ページも扉もないH1なので、Tennysonの詩と4ページ目を分け合いながら、最上位のしおりを得ます。表紙の題を2行に分ける\\は、しおりではスペースになります。

#5 · 題と著者はフロントマターから

script.js · 80–93行コード全体で見る
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};

フロントマターの値はすべて引用符で囲みます。表紙は{author}と{subtitle}でそのうち2つを印刷し、PDFの文書プロパティは同じブロックから題『Home from Sea』と著者「The Harbour Consort」を取ります。日付と会場は表紙の見出しの属性です。その背後の絵はページ幅のSVG1つで、PDFはそれをベクターのパスのまま保ちます。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════
// https://postext.dev/en/cookbook/pdf-with-embedded-fonts
// Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code
// Fonts: Crimson Text, Fraunces, Tenor Sans (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 = 'pdf-with-embedded-fonts';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: one download per face: the layout measures it, the PDF embeds it
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
// #endregion

const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#1a2326', band: '#0f2a33', // text, a sea-green near-black; night teal: cover and titles
  gilt: '#c9a227', bronze: '#806414', // the accent; deepened to 5.4:1 for small type on paper
  foam: '#e3ebe8', rule: '#b9c6c2', // cover small type and the table's total; hairlines
  muted: '#5c6b70', paper: '#fbfaf6' }; // feet and colophon; the page
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.band]] // the defaults'
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // id: teal, never blue

const PAGE = { width: 148, height: 210 }; // mm: an A5 programme
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 28 }; // mm, mirrored: a 102 mm measure
const LEAD = 13.3; // pt: the leading of text and verse, 1.33 × the 10 pt body
const LABEL = 7.5, TRACK = 0.2; // pt: kickers, feet, table head, date; em: capitals' tracking
const H2 = { italic: true, fontSize: pt(13.5), lineHeight: pt(2 * LEAD) }; // two lines of text

const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size),
  letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) });
const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true,
  fontSize: pt(size), lineHeight: 1 }); // a multiple (gotcha: design-lineheight-multiple)
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default)
const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });

// #region cover: the drawing fills the page; the frontmatter and the heading set the type
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};
// #endregion

const opener = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // kicker, title, rule
  text('kicker', '{attr.kicker}', at('container', 'top-left', 0), label(LABEL, 'bronze')),
  text('title', '{titleText}', at('#kicker', 'below', 1.5), { ...title(26), color: col('band') }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('gilt'),
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(14) } } },
] } };

const foot = (parity, edge, x, content) => ({ kind: 'text', id: parity, content, parity,
  ...label(LABEL, 'muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x),
    y: mm(-MARGIN.bottom / 2) } } });

// #region headings: the heading tree is the bookmark tree
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
// #endregion

const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  colorPalette, resourceTypes: [plain],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: { fontFamily: 'Crimson Text', fontSize: pt(10), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), boldFontWeight: 600,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 },
  headings, headingStyles: [cover, aside], layout: { layoutType: 'single' },
  paragraphStyles: [ // verse: a paragraph per line, never stretched if a line ever turns over
    { id: 'verse', textAlign: 'left', firstLineIndent: pt(0) },
    { id: 'verse-in', textAlign: 'left' }, // a line the poet indented: the body's 4.5 mm
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(6.5), lineHeight: pt(9.3),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: 'Tenor Sans',
    headerFontSize: pt(LABEL), headerBold: false, bodyFontSize: pt(9), cellPadding: mm(1.2) },
  header: { elements: [] }, footer: { elements: [ // no running heads: folios in the feet, 10 mm up
    foot('even', 'bottom-left', MARGIN.outer, '{pageNumber} · {title}'), // verso: the programme
    foot('odd', 'bottom-right', -MARGIN.outer, '{chapterTitle} · {pageNumber}')] }, // recto
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 162行 · content.en.mdtitle: "Home from Sea" subtitle: "Three new songs and older water music for soprano, cello and harp" author: "The Harbour Consort" --- # Home \\ from Sea {style="cover" when="Saturday 17 October 2026 · 6 pm" where="The Sail Loft, Kellan Harbour"} # Programme {kicker="Twilight recital · 17 October 2026"} ::resource{id="order"} ## About the music Hester Vane wrote *Home from Sea* for the Harbour Consort last winter, and tonight is its first performance. Its three songs set poems written within ten years of one another, and between them the consort plays older water music in its own arrangements, so that each new song follows a piece the room may already know. The poems follow on the next pages, in the order in which they are sung. Mendelssohn’s boat song rocks in six-eight; then, in Longfellow’s *The Tide Rises, the Tide Falls*, the harp keeps the tide turning and the cello takes the curlew’s call. Fauré wrote his *Élégie* in 1880 as the slow movement of a cello sonata he never finished; its lament leads into Stevenson’s *Requiem*, set almost as a folk song. Debussy’s *La cathédrale engloutie*, a piano prelude that Lior Bensaid has arranged for harp, follows the Breton legend of a church that rises from the sea on clear mornings and sinks again. Last comes Tennyson’s *Crossing the Bar*, which the poet asked to have placed at the end of every collection of his poems. Vane gives it the same place in her cycle. # The texts {kicker="Home from Sea · three songs"} ## The Tide Rises, the Tide Falls :::paragraphs{style="verse"} The tide rises, the tide falls, The twilight darkens, the curlew calls; Along the sea-sands damp and brown The traveller hastens toward the town, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space Darkness settles on roofs and walls, But the sea in the darkness calls and calls; The little waves, with their soft, white hands, Efface the footprints in the sands, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space The morning breaks; the steeds in their stalls Stamp and neigh, as the hostler calls; The day returns, but nevermore Returns the traveller to the shore, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: ::: :::space ## Requiem :::paragraphs{style="verse"} Under the wide and starry sky, Dig the grave and let me lie. Glad did I live and gladly die, :::paragraphs{style="verse-in"} And I laid me down with a will. ::: :::space This be the verse you grave for me: *Here he lies where he longed to be*; *Home is the sailor*, *home from sea*, :::paragraphs{style="verse-in"} *And the hunter home from the hill*. ::: ::: :::pagebreak ## Crossing the Bar :::paragraphs{style="verse"} Sunset and evening star, :::paragraphs{style="verse-in"} And one clear call for me! ::: And may there be no moaning of the bar, :::paragraphs{style="verse-in"} When I put out to sea, ::: :::space But such a tide as moving seems asleep, :::paragraphs{style="verse-in"} Too full for sound and foam, ::: When that which drew from out the boundless deep :::paragraphs{style="verse-in"} Turns again home. ::: :::space Twilight and evening bell, :::paragraphs{style="verse-in"} And after that the dark! ::: And may there be no sadness of farewell, :::paragraphs{style="verse-in"} When I embark; ::: :::space For tho’ from out our bourne of Time and Place :::paragraphs{style="verse-in"} The flood may bear me far, ::: I hope to see my Pilot face to face :::paragraphs{style="verse-in"} When I have crost the bar. ::: ::: # The performers {style="aside"} **The Harbour Consort** was formed in 2019 by three musicians who had played together at the town’s lifeboat-day concerts for years: Morwenna Hale, soprano, Ada Pryor, cello, and Lior Bensaid, harp. Its twilight recitals in the Sail Loft run from October to March. Hester Vane, the consort’s composer this season, writes mostly for voices and small ensembles, and *Home from Sea* is her second song cycle. :::paragraphs{style="colophon"} Set in Crimson Text, Fraunces and Tenor Sans (SIL Open Font License), from the same font files on screen and in this PDF. Poems by Longfellow (1880), Stevenson (1887) and Tennyson (1889), public domain; notes CC BY 4.0. Town, consort and composer are imagined. :::
`; // content.<lang>.md, inlined by the Cookbook const plain = { id: 'plain', name: 'Programme', shortLabel: '', captionPrefix: '', // no "Table 1" numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const row = (who, what, time, more) => [who, what, time].map((content, i) => ({ content, align: i === 2 ? 'right' : 'left', ...more })); // durations flush right const resources = [ { id: 'order', typeId: 'plain', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, // where ::resource sets it, not floated to the foot table: { model: { headerRowCount: 1, columnWidths: [28, 55, 17], rows: [ row('COMPOSER', 'WORK', 'DURATION', { isHeader: true }), // capitals: the head is a label row('Felix Mendelssohn', '*Venetian Boat Song*, op. 30 no. 6', '3′05″'), row('Hester Vane', '*The Tide Rises, the Tide Falls* · Longfellow', '4′20″'), row('Gabriel Fauré', '*Élégie*, op. 24', '6′50″'), row('Hester Vane', '*Requiem* · Stevenson', '3′10″'), row('Claude Debussy', '*La cathédrale engloutie*', '6′15″'), row('Hester Vane', '*Crossing the Bar* · Tennyson', '5′40″'), row('', 'About half an hour, without an interval', '29′20″', { background: col('foam') }), ] } } }, { id: 'cover', typeId: 'plain', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: PAGE.width * 10, height: PAGE.height * 10 }, altText: 'Night-teal cover whose lower half is rows of gilt wave scales, fading upward.' }, ]; // #region art: seigaiha, the blue-sea-wave pattern, as gilt rings on night teal const WAVES = 115; // mm from the top edge: where the waves begin, under the venue function coverArt(w, h, top) { // mm: the page, and where the waves begin const R = 12.5; // mm: the radius of one scale const f = (n) => +n.toFixed(2); const rows = Math.ceil((h - top) / (R / 2)) + 1; let out = `<rect width="${w}" height="${h}" fill="${palette.band}"/>`; for (let i = 0; i <= rows; i++) { // top row first: each row hides the lower half of the last const y = top + (i * R) / 2; const glow = f(0.5 + 0.5 * (i / rows) ** 1.3); // half-lit at the top, full gilt at the foot for (let x = (i % 2) * R; x <= w + R; x += 2 * R) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${R}" fill="${palette.band}"/>`; for (const k of [0.9, 0.64, 0.38]) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(k * R)}" fill="none" ` + `stroke="${palette.gilt}" stroke-width="${f(0.09 * R)}" stroke-opacity="${glow}"/>`; } out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(0.12 * R)}" fill="${palette.gilt}" ` + `fill-opacity="${glow}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}"><clipPath id="page"><rect width="${w}" height="${h}"/></clipPath>` + `<g clip-path="url(#page)">${out}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Each bold and italic a block may ask for. Tenor Sans has 400 only (gotcha: faked-font-styles). const FONTS = { 'Crimson Text': ['400', '400i', '600', '600i'], Fraunces: ['300', '300i'], 'Tenor Sans': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the faces first, then the layout, then a check that nothing was missed await registerFaces(); // the answer: every face in FONTS, from its own bytes await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES)); // buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds. const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Home from Sea · a recital programme' }); // #endregion // #region pdf: the export: bookmarks from the headings, a progress bar, the faces it embedded const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 }); const list = (faces) => [...faces].join(', ') || 'none'; offerPdf(() => { document.getElementById('pt-actions').prepend(bar); return renderToPdf(doc, { fontProvider, // the answer: the page's own font files resourceBytes: imageBytes, // the cover drawing, as vector paths outlines: true, // the default, spelled out: each heading becomes a bookmark onProgress: ({ phase, pages, totalPages }) => { bar.value = pages / totalPages; const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`, save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` }; kitStatus(`PDF · ${says[phase]}`); }, }); }, `${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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#しおりを入れない

1ページのチラシやポスターには、しおりを付けるものがありません。outlines: falseにするとPDFにアウトラインはなく、サイドバーを閉じた状態で開きます。

-    outlines: true, // the default, spelled out: each heading becomes a bookmark
+    outlines: false,

#フォントを自分で配信する

取得先を、同じ静的WOFF2ファイルの自前のコピーに向けます。ウェイトとスタイルごとに1ファイルで、ファイル名はFontsourceと同じにします。ページとPDFは、引き続き各ダウンロードを共有します。

-    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
+    files.set(file, fetch(`/fonts/${file}.woff2`)
       .then((res) => {
-        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
+        if (!res.ok) throw new Error(`No font file ${file}.woff2`);

よくあるつまずき

つまずき

レイアウトの前にすべてのフォントを読み込む

レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →

つまずき

PDFはすべてのファミリーのすべてのウェイトとスタイルを要求する

renderToPdfは、ブロックが使う可能性のあるすべてのファミリーについて、実際には印字されないものも含め、ボールド、イタリック、ボールドイタリックのフォントをフォントプロバイダーに求めます。1つでも拒否されると書き出しが止まります。プロバイダーは、そのファミリーが持つ最も近いウェイトを返し、イタリックがなければ立体にフォールバックする必要があります。 PDFに埋め込むフォント →

つまずき

ファミリーにないボールドやイタリックは、画面では擬似的に作られ、PDFでは作られない

ファミリーが持たないウェイトやスタイル(1書体しかないラベル用フォントでの太字の表ヘッダー、イタリックのないサンセリフでのイタリック)を求めると、ブラウザーはCanvasとHTMLでそれを合成し、同じ幅のまま立体を太らせたり傾けたりします。PDFは実在するフォントしか埋め込まないため、そこではプロバイダーが返す最も近いフォントで通常の形のまま印字されます。フォントの一覧にはファミリーが持つフォントだけを入れ、tableStyle.headerBold: falseのように、すべてのスタイルをそれに合わせてください。 PDFに埋め込むフォント →

つまずき

Fontsourceのlatinファイルにはラテン文字以外のグリフがない

PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。 PDFに埋め込むフォント →

つまずき

フロントマターの値はすべて引用符で囲む

YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください(title: "1984")。 文書のメタデータ →

つまずき

headingsオブジェクトを渡すとH1の改ページが消える

既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →

つまずき

見出しスタイルはその階層の改ページを受け継ぐ

headingStylesの項目は、指定しなかったフィールドをすべて見出しの階層から受け継ぎ、breakBeforeも例外ではありません。:::pagebreakのあとのH1に付けた目次や奥付のスタイルはparity 'odd'を受け継ぎ、白ページの後ろに置かれます。こうしたスタイルにはbreakBefore: { enabled: false }を指定してください。 見出しスタイル →

つまずき

パレットを差し替えても、デザイン要素と参照色は変わらない

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'を設定してください。 ページデザインのテキスト・罫・ボックス →

つまずき

設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る

エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →

本文が使いうる太字とイタリックは、すべてFONTSに並べます。1つでも欠けていると、キットが警告なしにその書体を読み込むので画面は正しく見えますが、PDFはプロバイダーの最も近い書体で印刷され、入れ替わりに気づける手がかりは状態の行に出る代用だけです。

静的なファイルを、ウェイトとスタイルごとに1つずつ使います。可変WOFF2からは既定のインスタンスしか埋め込まれないので、太字の部分がレギュラーのウェイトで印刷されてしまいます(なぜフォントプロバイダーが必要か)。

クレジット

レシピ
Ignacio Ferro
本文
画像
  • The seigaiha waves on the cover, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
フォント
Crimson Text (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
SandboxPDF