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

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

組版前にすべてのフォントを読み込む書体見本

4ページの書体見本。1回目の組版でページが使う書体の名前がすべてそろい、サンプルはそれを読み込み、幅のキャッシュを消してから組み直します。

このページの内容

1ページ(全4ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり180 × 240 mm
  • 1段
  • Ysabeau Office 11/15.5
  • IBM Plex Mono
  • Noto Serif Display
  • 4ページ
  • レベル
  • Postext 1.4.1
  • 組版時間4 ms
  • コード216行

かんたんな説明

3つの書体を見せる4ページの小冊子。ページを組む前にすべてのフォントを読み込み終えておかないと、行が間違った位置で改行される理由を示します。

できあがり

架空の出版社Pellow Lane Pressの『House Specimen Nº 3』は、3つの書体を見せる4ページの書体見本です。表紙では、群青色の地に240 ptのNoto Serif Displayイタリックで白い「Ag」を置き、3つの書体の名前を記した等幅の2行を添えます。題はその地の下に組みます。2ページ目はYsabeau Officeを7 ptから14 ptまでのウォーターフォールで見せ、続いてスペイン語、ポーランド語、チェコ語のパングラムを組みます。そこに含まれるż、ř、ůには2つ目のフォントファイルが要ります。3ページ目は検査表で、レイアウトが求めた10の書体とそのサイズを、サンプルが書き込むIBM Plex Monoの表に並べます。4ページ目には1ページ目の最初の段落を、1回目の組版の結果と最後の組版の結果で2回組みます。1回目の組版はフォントが届く前に走ったので、行末の位置が異なり、いくつかの行は行長からはみ出します。

このレシピが答える質問

  • 行の分割が変わったりPDFで単語が重なったりするのはなぜですか?フォントを正しく読み込むには?
  • 自社ブランドのフォントやライセンスを受けたフォントでレイアウトし、PDFに埋め込むには?
  • 文書のどこに問題があるか(警告、はみ出し、収束しないレイアウト)を調べるには?

手短な答え

script.js · 283–322行コード全体で見る
// Every block, table, caption, chip, opener and running head keeps the font string it is set in
// (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…).
function fontStringsIn(doc) {
  const found = new Map(); // font string → true when something is set in it
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    for (const [key, value] of Object.entries(node)) {
      if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value);
      else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key));
    }
  };
  walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings
  return found;
}
function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px }
  const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font);
  return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px),
    style: italic ? 'italic' : 'normal' };
}
const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too
async function buildWithLoadedFonts(build, sample) { // → every build, first to last
  const builds = [];
  while (builds.length < 4) {
    builds.push(build()); // the first one measures with whatever faces the browser has
    // fonts.check() says yes to an undeclared family and to a face it can fake, so each face that
    // something is set in needs a FontFace of its own; a bold or italic that is only named loads
    // if declared (a family with no italic has none). load() fetches the files the sample needs.
    const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = [];
    for (const [font, set] of fontStringsIn(builds.at(-1))) {
      const name = nameOf(faceOf(font));
      if (!declared.has(name)) { if (set) missing.add(name); }
      else if (!document.fonts.check(font, sample)) pending.push(font);
    }
    if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`);
    if (!pending.length) return builds;
    await Promise.all(pending.map((font) => document.fonts.load(font, sample)));
    clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
  }
  throw new Error(`The fonts had not settled after ${builds.length} builds.`);
}

材料

種類
Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1)
素材
なし:図はすべてコードで描画

作り方

#1 · ファイルを取得せずにすべて宣言する

script.js · 260–279行コード全体で見る
const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS
  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' };
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
  for (const [family, specs] of Object.entries(fonts)) {
    const id = family.toLowerCase().replaceAll(' ', '-');
    for (const spec of specs) {
      const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal'];
      for (const [subset, unicodeRange] of Object.entries(SUBSETS)) {
        const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        // Adding a face fetches nothing: the file downloads when a load or a line needs it.
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`;
        document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange }));
      }
    }
  }
}

FontFaceは、@font-face規則と同じく1つのファイルを表します。latinとlatin-extの2ファイルからなる書体なら、ファミリー、ウェイト、スタイルが同じでunicodeRangeだけが違う2つのFontFaceオブジェクトで宣言します。範囲の値はファミリーのCSSから写します。document.fontsに加えただけでは何もダウンロードされません。ブラウザーがファイルを取りに行くのは、load()が呼ばれたときか、その文字を必要とするテキストの行が現れたときです。buildDocumentはフォントを読み込みません(読み込むのは.postextバンドル内のファイルを扱うloadBundleFontsだけです)。設定のcustomFontsもそれらのファイルを記述するだけなので、Markdownから組むページは自分でフォントを宣言する必要があります。

#2 · 組む、読み込む、消す、組み直す

script.js · 348–355行コード全体で見る
kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
declareFaces(FONTS);
const build = () => buildDocument({ markdown, resources: resources() }, config());
const builds = await buildWithLoadedFonts(build, markdown);
audit = auditOf(builds); // page 3's table
drawProof(builds[0], builds.at(-1)); // page 4's picture
const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load
showPages(doc, { title: 'Load every font before layout' });

1回目の組版は、ブラウザーがその時点で持っているもの、つまり代替書体で計測します。それでも、そのページにはすでに必要なフォントの名前がすべてそろっています。上の手短な答えにあるbuildWithLoadedFontsは、組版ごとにその名前を集め、まだ読み込まれていない書体を小冊子の本文をサンプル文字列にして読み込み、読み込むものが残らなくなるまで組み直します。このサンプル文字列では、それらの文字を1つも組まないディスプレイ書体と等幅書体のlatin-extファイルまで取得されます。ダウンロード量が気になるなら、書体ごとにその書体で組む文字列だけを渡して読み込みます。clearMeasurementCache()は引数を取らず、1回目の組版がキャッシュした幅を空にします。これがないと、2回目の組版は1回目の改行位置をすべて引き継ぎます(アレンジを参照)。

#3 · 検査表をレイアウトから作る

script.js · 326–343行コード全体で見る
function auditOf(builds) {
  const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf));
  for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) {
    const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic'
    const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first
    // A face with no file is only named, never set: the browser fakes it if a line asks for it.
    if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(),
      face: declared.has(nameOf(face)) ? name : `${name} · no file` });
    faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt
  }
  const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [
    i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
    f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content })));
  const files = [...document.fonts].filter((face) => face.status === 'loaded').length;
  const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout
  return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · `
    + `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` };
}

表は同じ走査を書体ごとにまとめたもので、各サイズはレイアウトのピクセルからポイントに換算し直しています。表の注記は、その表が記述する組版からさらに2つのフィールドを読みます。レイアウトのパスが収束するとtrueになるconvergedと、1.4.1ではどの段にも収まらない囲みを列挙するwarningsです。ページを表示したり書き出したりする前に、両方を確かめてください。表にはページに見える書体より多くの書体が並びます。本文ブロック、表、キャプションはどれも、自身の書体に加えて太字、イタリック、太字イタリックの名前を挙げ、renderToPdfはそのすべてを求めるからです。手短な答えは、何かを組むのに使う書体ごとに専用のFontFaceを必須にしています。document.fonts.check()は、宣言していないファミリーにも、ブラウザーが擬似的に作れる太字にもtrueを返すからです。名前を挙げられただけの書体は、宣言されていれば読み込まれます。イタリックのファイルがないファミリーは検査を通り、表はそのイタリックにno fileと記します。

#4 · 1回目の組版を証拠として残す

script.js · 124–168行コード全体で見る
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0 }; // lines of text the first build broke elsewhere, of all
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the first build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The first build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the first build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['first', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}

buildWithLoadedFontsはすべての組版を返します。そのためサンプルは、本物の書体がそろった後で1回目の組版を描き直し、最後の組版と1行ずつ比べられます。キャプションの行数はこの比較から出ています。幅の狭い代替書体で計測した普通の行は行長を越え、段の端から2 pt外にあるクリップで最後の文字が切り取られます。そこでサンプルは、はみ出し全体が見えるように段を広げて1ページ目を描きます。Canvasとpostext-pdfは、両端そろえの行や複数のスタイルが混じる行の各ランを、レイアウトが計測した位置に描きます。そのため代替書体より幅の広い本物の書体は次の単語に重なって印刷され、フォントが届く前に生成したPDFでは単語どうしが重なります。

#5 · 本文のリズムに乗るウォーターフォール

script.js · 31–45行コード全体で見る
const bodyText = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name)
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];

本文はYsabeau Officeの11 pt、行送り15.5 ptで組み、段落の間は字下げでなく1行空けます。本文は左そろえ(行末不ぞろい)なので、どの語間も自然な幅を保ちます。太字、イタリック、参照はインク色で組みます。指定しなければメインカラーになるところです。ウォーターフォールの各サイズとパングラムは、どれも行送り2つ分(31 pt)の高さを持つ段落スタイルなので、ページは本文の15.5 ptのリズムを保ちます。サイズのラベルは等幅のチップです。1桁のサイズには左右に半文字分のパディングを付けるので、どの見本も同じ端から始まります。

#6 · 1枚の表紙に3つの書体

script.js · 49–76行コード全体で見る
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // design text wraps instead of ending in an ellipsis
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break)
  span: 'page', // lets the field reach the top edge: in the column it stops at the top margin
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      // lineHeight multiplies the size (gotcha: design-lineheight-multiple)
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });

表紙はH1レベルの見出しデザインです。見出しのglyphs属性の文字をディスプレイ書体の240 ptで組み、その下に、活字鋳造所の見本帳のように、その書体と本文用、ラベル用の書体の名前を記した等幅の2行を置きます。span: 'page'を指定すると、裁ち落とし基準で置いた地を上端からはみ出させられます。段の中に収めたままだと、デザインは天の余白でクリップされ、地の上部と見出し上のラベルが切れます。minHeightは天の余白から数え、地から42 mm下のY.endまでの領域を確保します。これがないと本文が1行上から始まり、リードのすぐ下に詰まります。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 026 · Type specimen with every font loaded before layout ═════════
// https://postext.dev/en/cookbook/fonts-before-layout
// Code: MIT · Text: original (CC BY 4.0) · Picture: cut from the pen's own first and last builds
// Fonts: Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'fonts-before-layout';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const PAGE = { width: 180, height: 240 }; // mm
const MARGIN = { top: 22, bottom: 24, inner: 20, outer: 48 }; // mm: inner is the spine side
const MEASURE = PAGE.width - MARGIN.inner - MARGIN.outer; // 112 mm: about 70 letters at 11 pt
const FIELD = 122; // mm from the top edge: the ultramarine field of the opener
const LEAD = 15.5; // pt: the body leading and the step of every vertical space
const DPI = 150; // font strings carry px at this resolution; the audit turns them back into pt
const [TEXT, DISPLAY, MONO] = ['Ysabeau Office', 'Noto Serif Display', 'IBM Plex Mono'];
const palette = { ink: '#16161a', ultramarine: '#3246d3', mist: '#c9d0f6', // mist: 4.6:1 on
  rule: '#cfc9bd', muted: '#6b6a70', paper: '#ffffff' }; // ultramarine, for labels on the field
// Every colour keeps its palette id beside its hex, because 1.4.1 paints design slots from the
// hex (gotcha: palette-skips-designs); main-color catches any default left unstated.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => Object.entries({ ...palette, 'main-color': palette.ultramarine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const label = { fontFamily: MONO, fontSize: pt(7.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase' };
const display = { fontFamily: DISPLAY, fontWeight: 900, italic: true };

// #region type: the text face at 11 on 15.5 pt, and a waterfall of it on two leads a line
const bodyText = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name)
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];
// #endregion

// #region opener: the H1 as a bleed field, the display face at 240 pt, labels naming the faces
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // design text wraps instead of ending in an ellipsis
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break)
  span: 'page', // lets the field reach the top edge: in the column it stops at the top margin
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      // lineHeight multiplies the size (gotcha: design-lineheight-multiple)
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });
// #endregion

// Running heads at the outer edge of the text; a drop folio there too on the opener (a recto).
const HEADS = { top: 13, bottom: 12 }; // mm from the top and the bottom edge of the page
const head = (id, content, parity, edge, x, pages = 'body') => text(id, content, { parity,
  pages, fontFamily: MONO, fontSize: pt(7.5), color: col('muted') }, { anchor: { to: 'page',
  edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEADS.top : -HEADS.bottom) } });
const header = () => ({ elements: [
  head('verso', '{pageNumber} · {title}', 'even', 'top-left', MARGIN.outer),
  head('recto', '{chapterTitle} · {pageNumber}', 'odd', 'top-right', -MARGIN.outer)] });
const footer = () => ({ elements: [
  head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -MARGIN.outer, 'opener')] });

const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  // "Table 1", not "Table 1.1": the booklet has one chapter. Table captions sit above.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
  colorPalette: colorPalette(), layout: { layoutType: 'single' },
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: DPI,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: bodyText(), paragraphStyles: paragraphStyles(), chipStyles: chipStyles(),
  headings: { fontFamily: DISPLAY, fontWeight: 900, color: col('ink'), levels: [cover(),
    { level: 2, fontSize: pt(16), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }] },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ultramarine'), headerColor: col('paper'), headerFontFamily: MONO,
    headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(7.5), cellPadding: mm(1) },
  captionStyle: { fontFamily: MONO, fontSize: pt(7.5), labelColor: col('ultramarine'),
    note: { color: col('muted') } },
  header: header(), footer: footer(),
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The table and the picture come from the builds themselves (section 4).
let audit = { rows: [], note: '' };
const here = { position: 'here' }; // both sit where ::resource puts them
const resources = () => [
  { id: 'faces', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: here,
    caption: 'Faces this document asked for, read from its own layout.', note: audit.note,
    table: { model: { headerRowCount: 1, columnWidths: [3, 2, 5], rows: [
      ['Family', 'Face', 'Sizes (pt)'].map((content) => ({ content, isHeader: true })),
      ...audit.rows] } } },
  proofFigure(), // drawn from the builds just below
];

// #region art-proof: page 1's first paragraph from the first build, over the same from the last
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0 }; // lines of text the first build broke elsewhere, of all
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the first build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The first build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the first build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['first', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}
// #endregion

const markdown = String.raw`---
Markdownの見本 · 77行 · content.en.mdtitle: "House Specimen" author: "Pellow Lane Press" --- # Three faces, proofed {kicker="Pellow Lane Press · House specimen Nº 3" lead="Our text, display and label faces at work, and proof that each of them had arrived before these lines were set." glyphs="Ag" label="Noto Serif Display 900 italic · 240 pt" faces="Text: Ysabeau Office · Labels: IBM Plex Mono"} A compositor in a metal shop could only set a line in a face that was in the case. Postext measures every word with the fonts the browser holds at that moment and keeps the widths, so a face that arrives a second late leaves the page broken for a fallback, with no warning. These pages were built three times: once to learn which faces the layout asks for, again once all of them had loaded, and a last time to print their list on page 3 and, on page 4, what the first build got wrong. ## Seven sizes of the text face Ysabeau, drawn by Christian Thalmann, carries the letterforms of the Garamond tradition into a low-contrast sans serif. Its Office cut sets tabular lining figures and a level hyphen by default. :::paragraphs{style="s7"} :chip[7 pt]{style="size-1"} Credits and map legends, where small print needs open counters. ::: :::paragraphs{style="s8"} :chip[8 pt]{style="size-1"} Captions and table notes, where the tabular figures keep 1,048 and 2,096 in step. ::: :::paragraphs{style="s9"} :chip[9 pt]{style="size-1"} A reference column set close; the long ascenders keep the lines apart. ::: :::paragraphs{style="s10"} :chip[10 pt]{style="size"} Notes and asides, a size below the text they sit beside. ::: :::paragraphs{style="s11"} :chip[11 pt]{style="size"} The text of this booklet, eleven on fifteen and a half. ::: :::paragraphs{style="s12"} :chip[12 pt]{style="size"} A standfirst, or a first reader for children. ::: :::paragraphs{style="s14"} :chip[14 pt]{style="size"} A heading, or a line on a poster. ::: ## Beyond Latin-1 Spanish needs nothing beyond the latin file of each face. Polish and Czech need more: ż, ł, ř and ů live in a second file, latin-ext, which the browser fetches only when a load or a line asks for those letters. :::paragraphs{style="pangram"} :chip[es]{style="size"} El veloz murciélago hindú comía feliz cardillo y kiwi. :chip[pl]{style="size"} Zażółć gęślą jaźń. :chip[cs]{style="size"} Příliš žluťoučký kůň úpěl ďábelské ódy. ::: :::pagebreak ## The proof The script compiled the table below from the layout. After the first build, it walked the finished pages for every font they had asked for, in the text, headings, chips, tables, captions, opener and running heads. It loaded each face that had not arrived, emptied the measurement cache and built the pages again, then wrote down what it had found. ::resource{id="faces"} Some faces in the table set nothing in this booklet. Beside the face of every block of text, table and caption, Postext names a bold, an italic and a bold italic, whether the text uses them or not, and a PDF export asks for all of them. The script loads each one it has a file for. Before loading anything, the script also checked that every face some text is set in had a file of its own. It could not rely on the browser’s check, which answers yes for a family nobody declared and for any bold it can fake by thickening the regular. :::pagebreak ## What the first build got wrong The first build ran before any of these files had arrived, so the browser measured its words in a fallback face. Drawn in the real faces, its lines no longer fit the measure. ::resource{id="proof"} The fallback widths stay in the measurement cache, and a second build made without emptying it breaks every line where the first one did. :::paragraphs{style="colophon"} Set in Ysabeau Office, Noto Serif Display and IBM Plex Mono (SIL OFL 1.1) · Text: original, CC BY 4.0 · Pellow Lane Press is imaginary. :::
`; // content.<lang>.md: every frontmatter value is quoted // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the layout asks for (page 3 lists them), each declared from two files. const FONTS = { 'Ysabeau Office': ['400', '400i', '700', '700i'], // text, waterfall, pangrams, lead 'Noto Serif Display': ['900', '900i'], // the glyphs, the title, the subheads 'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions }; // #region declare: one FontFace per file, as a stylesheet has one @font-face rule per file const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS 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' }; function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder for (const [family, specs] of Object.entries(fonts)) { const id = family.toLowerCase().replaceAll(' ', '-'); for (const spec of specs) { const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal']; for (const [subset, unicodeRange] of Object.entries(SUBSETS)) { const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; // Adding a face fetches nothing: the file downloads when a load or a line needs it. const url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`; document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange })); } } } } // #endregion // #region answer: build, collect every font the layout asked for, load it, clear, build again // Every block, table, caption, chip, opener and running head keeps the font string it is set in // (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…). function fontStringsIn(doc) { const found = new Map(); // font string → true when something is set in it const walk = (node) => { if (!node || typeof node !== 'object') return; for (const [key, value] of Object.entries(node)) { if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value); else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key)); } }; walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings return found; } function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px } const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font); return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px), style: italic ? 'italic' : 'normal' }; } const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too async function buildWithLoadedFonts(build, sample) { // → every build, first to last const builds = []; while (builds.length < 4) { builds.push(build()); // the first one measures with whatever faces the browser has // fonts.check() says yes to an undeclared family and to a face it can fake, so each face that // something is set in needs a FontFace of its own; a bold or italic that is only named loads // if declared (a family with no italic has none). load() fetches the files the sample needs. const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = []; for (const [font, set] of fontStringsIn(builds.at(-1))) { const name = nameOf(faceOf(font)); if (!declared.has(name)) { if (set) missing.add(name); } else if (!document.fonts.check(font, sample)) pending.push(font); } if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`); if (!pending.length) return builds; await Promise.all(pending.map((font) => document.fonts.load(font, sample))); clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared } throw new Error(`The fonts had not settled after ${builds.length} builds.`); } // #endregion // #region audit: page 3's table, one row per face the walk found, with every size it set function auditOf(builds) { const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf)); for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) { const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic' const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first // A face with no file is only named, never set: the browser fakes it if a line asks for it. if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(), face: declared.has(nameOf(face)) ? name : `${name} · no file` }); faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt } const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [ i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content }))); const files = [...document.fonts].filter((face) => face.status === 'loaded').length; const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · ` + `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` }; } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: declare the files, build until the fonts settle, audit, build the last time kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below declareFaces(FONTS); const build = () => buildDocument({ markdown, resources: resources() }, config()); const builds = await buildWithLoadedFonts(build, markdown); audit = auditOf(builds); // page 3's table drawProof(builds[0], builds.at(-1)); // page 4's picture const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load showPages(doc, { title: 'Load every font before layout' }); // #endregion
キット · core, fonts, viewer:全レシピ共通 · 235行// ─── 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 ───────────────────────────────────────────────────────────────────────

組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#キャッシュを消さない

サンプルはすべての書体を読み込みますが、2回目の組版は1回目の改行位置をすべて引き継ぎます。4ページ目の2つの帯はどちらも線を越え、キャプションが示す別の位置で改行された行は0行になります。等幅のキャプションの単語は互いに重なって印刷されます。各ランが、代替書体で計測した位置に描かれるからです。

-    clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
+    // clearMeasurementCache();

#書体を1つ忘れる

見出し上のラベルと表の見出し行を組むIBM Plex Monoの太字を外すと、サンプルはページを表示する前にNo FontFace for IBM Plex Mono 700 normalを投げます。document.fonts.check()だけで確かめていたら、ブラウザーはレギュラーを太らせて済ませていたはずです。

-  'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
+  'IBM Plex Mono': ['400', '400i', '700i'], // labels, chips, the table, captions

#同じ書体をPDFに埋め込む

renderToPdfは、各書体についてfontProviderが返すバイト列を埋め込みます。同じフォントを埋め込んだ本物のPDFのプロバイダーはFontsourceのlatinファイルを返しますが、これはLatin-1までしか収録していません。この小冊子のż、ł、ř、ůのためには、プロバイダーが自前のフォルダーから各書体のフォントファイル全体を返すようにします。

よくあるつまずき

つまずき

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

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

つまずき

fontFamilyはファミリー名1つ。CSSのスタックは書けない

'Lora, serif'のようなスタックは、存在しない1つのファミリーとして読まれます。その結果、テキストは何の知らせもなく代替フォントで計測され、Canvas、HTML、PDFの結果が食い違います。ファミリーは1つだけ指定してください。 レイアウト前のフォント読み込み →

つまずき

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

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

つまずき

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

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

つまずき

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

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

つまずき

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

Sandboxの検査 · missingFont

フォントの欠落

原因. 設定で指定したフォントファミリーがブラウザーに読み込まれなかったため、テキストはシステムの代替フォントで計測・描画されました。

対処. ファミリー名を修正し(指定するファミリーは1つだけで、CSSのフォントスタックは使いません)、最初のレイアウトの前にすべてのフェイスを読み込んでください。CodePenのサンプルではFONTSに列挙します。 ドキュメント →

Sandboxの検査 · missingFontVariant

フォントのバリアントがありません

原因. カスタムファミリーに、文書が使うウェイトとスタイル(イタリックや太字など)のファイルがありません。

対処. 不足しているバリアントをアップロードまたは宣言するか、そのウェイトやスタイルを使わないようにしてください。 ドキュメント →

  • サンプル文字列なしのdocument.fonts.load(font)は、スペースを含むファイル、つまりlatinのファイルしか読み込みません。文字列を渡さないと、żやřは警告なしにシステム書体へ置き換わります。
  • renderPageToCanvasとpostext-pdfは各段を端から2 pt外でクリップします。そのため幅の狭い代替書体で計測した行は最後の文字を失い、警告も出ません。

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
フォント
Ysabeau Office (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
Sandbox