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

レシピ集 · 第5章 · 本の構成

読みで並べる五十音順の日本語索引

日本語の組版用語を扱う章とその索引。各マークは振り仮名かyomiで読みを持ち、項目はJISの順に、あ行、か行、さ行の下へ分類されます。

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

10–11ページ · 2–3/3

  • 英語の見本:日本語版はまだありません
  • 仕上がり148 × 210 mm
  • 1段
  • Noto Serif JP 9/16
  • Noto Sans JP
  • 3ページ
  • レベル
  • Postext 1.16.1
  • 組版時間71 ms
  • コード122行

かんたんな説明

日本の組版で使う言葉を説明する短い章と、巻末の索引です。索引に載る語はそれぞれ読みを持ち、Postextはその読みを使って、あ行、か行といった行見出しの下に五十音順で並べます。

できあがり

架空の日本語組版の手引き『組版の言葉』の1章と、その索引です。章はページの各部の名前、文字サイズの名前、約物の規則を説明し、A5判、36字×28行のページに組みます。各用語は、説明している箇所で索引用にマークします。主役は索引です。日本語の漢字にはアルファベット順のような並びがないので、索引は各項目をかなの読みで、五十音表の順に、あ行、か行、さ行といった行見出しの下へ並べます。振り仮名の付いた用語はそれ自体が読みを示し、それ以外の漢字を含む用語はyomi属性で読みを書きます。中国人名の索引では、中国の人名をピンインと画数で並べます。

このレシピが答える質問

  • 日本語の索引を、読みに基づいて五十音順に並べるには?

手短な答え

script.js · 30–42行コード全体で見る
// Each mark gives the reading the entry sorts by. A term written with furigana gives it
// itself: :index[{版面|はん|めん}] files 版面 as はんめん. Any other term with a kanji says it
// with yomi: :index[明朝体]{yomi="みんちょうたい"}. Kana and Latin terms need nothing. The
// reading orders the entries in JIS X 4061 order (katakana as hiragana, small kana as large,
// voiced after plain, ー as the vowel before it); Latin terms come first, under A to Z.
const index = {
  groupBy: 'gojuon', // what 'auto' picks in a 'ja' document: heads あ行 か行 さ行 …
  fontFamily: MINCHO, fontSize: pt(8.5), lineHeight: pt(14),
  separator: ' ', locatorSeparator: '、', // 版面 3、5: an ideographic space, then 、
  see: { italic: false }, // → ルビ, → 柱も見よ: upright, no italic in Japanese
  groups: { fontFamily: GOTHIC, fontSize: pt(9), fontWeight: 700, color: col('vermilion'),
    marginTop: pt(8) },
};

材料

種類
Noto Serif JP, Noto Sans JP (SIL OFL 1.1)
素材
なし:図はすべてコードで描画

作り方

#1 · 漢字を含む用語にはかならず読みを

コードは前の手短な答えにあります。本文の:index[{版面|はん|めん}]{main}は、版面の上にはんめんを付けて刷り、項目をはんめんとして分類します。:index[明朝体]{yomi="みんちょうたい" main}は読みを刷らず、みんちょうたいとして分類します。ノンブルやのどのようなかなの用語と、DTPやJIS X 4051のようなラテン文字の用語には何も要りません(索引マーク)。読みが決めるのは並び順だけで、項目は書いたとおりに刷られます。天はここではてんですが、別の文脈ではあめと読むように、漢字の読みは推測できません。そのためエンジンは推測を試みません。

#2 · JISの順と行見出し

groupBy: 'gojuon'は、locale: 'ja'で'auto'が選ぶ値です。項目はJIS X 4061の順に並びます。カタカナはひらがなとして、小書きのかなは並のかなとして扱い、濁音のかなは清音のすぐあとに置き、ーは直前の母音とみなします。そのため級(きゅう)は行取り(ぎょうどり)の前に、ゴシック体(ごしっくたい)は小口(こぐち)のあとに来ます。ラテン文字の用語はJISの順どおり先頭に来て、それぞれの文字の見出しの下に並びます。記号と数字はさらにその前に来ます(索引)。

11ページ:DとJの下にラテン文字の用語、続いてか行からわ行までのかなの行。どの項目も読みの順に並びます。

#3 · →と「も見よ」

「見よ」参照はseeを持つマークで、:index{term="振り仮名" yomi="ふりがな" see="ルビ"}と書き、「振り仮名 →ルビ」と刷ります。「も見よ」参照はseealsoを使い、日本語の索引の書き方どおり、参照先を「も見よ」の前に置いて「級 10 →歯も見よ」と刷ります。日本語の書体にはイタリックがないので、see: { italic: false }でラベルを立体のままにします。各用語のあとの全角スペースと、ページの間の「、」は、separatorとlocatorSeparatorで決まります。

#4 · 2段にわたる索引

script.js · 46–60行コード全体で見る
const band = { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
  placement: { anchor: { to: 'page', edge: 'top-left' },
    size: { width: 'fill', height: mm(56) } } };
const opener = (sink, ground = []) => ({ enabled: true, minHeight: pt(PITCH * sink),
  slot: { elements: [...ground,
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(9),
    fontWeight: 700, letterSpacing: pt(2), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(10) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: GOTHIC, fontSize: pt(22),
    fontWeight: 700, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(3) } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
    color: col('vermilion'), placement: { anchor: { to: '#title', edge: 'below' },
      offset: { y: mm(4) }, size: { width: mm(12) } } },
] } });

索引の見出しは、span: 'page'と2段のlayoutを持つ見出しスタイルです。そのデザイン(題名を載せた淡い帯)はページ幅にまたがり、その下で:::indexが2段に流れます。行見出しは朱色のゴシック、項目は本文の明朝で、8.5 ptの文字を14 ptの行送りで組みます。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 126 · A Japanese index in gojūon order, read from its readings ═══
// https://postext.dev/en/cookbook/gojuon-index
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Noto Serif JP, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.16.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the chapter is Japanese in both
const RECIPE = 'gojuon-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, one vermilion for the row heads and the chapter label, pale rules
const palette = {
  ink: '#211e1c', // text: a warm near-black
  vermilion: '#b33a22', // the one accent: あ行 heads, the kicker, folios
  tint: '#f5ece6', // the index heading's band
  rule: '#d3c8c0', // the rule under each title
  muted: '#6c625b', // running heads, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.vermilion })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [MINCHO, GOTHIC] = ['Noto Serif JP', 'Noto Sans JP'];
const [BODY, PITCH] = [9, 16]; // pt: 9 pt text on a 16 pt line
const [CHARS, LINES] = [36, 28]; // the type area in characters

// #region answer: the index in gojūon order: by reading, under あ行 か行 さ行…
// Each mark gives the reading the entry sorts by. A term written with furigana gives it
// itself: :index[{版面|はん|めん}] files 版面 as はんめん. Any other term with a kanji says it
// with yomi: :index[明朝体]{yomi="みんちょうたい"}. Kana and Latin terms need nothing. The
// reading orders the entries in JIS X 4061 order (katakana as hiragana, small kana as large,
// voiced after plain, ー as the vowel before it); Latin terms come first, under A to Z.
const index = {
  groupBy: 'gojuon', // what 'auto' picks in a 'ja' document: heads あ行 か行 さ行 …
  fontFamily: MINCHO, fontSize: pt(8.5), lineHeight: pt(14),
  separator: ' ', locatorSeparator: '、', // 版面 3、5: an ideographic space, then 、
  see: { italic: false }, // → ルビ, → 柱も見よ: upright, no italic in Japanese
  groups: { fontFamily: GOTHIC, fontSize: pt(9), fontWeight: 700, color: col('vermilion'),
    marginTop: pt(8) },
};
// #endregion

// #region opener: the chapter label, the title and a short rule; the index's on a band
const band = { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
  placement: { anchor: { to: 'page', edge: 'top-left' },
    size: { width: 'fill', height: mm(56) } } };
const opener = (sink, ground = []) => ({ enabled: true, minHeight: pt(PITCH * sink),
  slot: { elements: [...ground,
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(9),
    fontWeight: 700, letterSpacing: pt(2), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(10) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: GOTHIC, fontSize: pt(22),
    fontWeight: 700, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(3) } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
    color: col('vermilion'), placement: { anchor: { to: '#title', edge: 'below' },
      offset: { y: mm(4) }, size: { width: mm(12) } } },
] } });
// #endregion

// Running heads: the book on the verso, the chapter on the recto, folios outside.
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: GOTHIC, fontSize: pt(7.5), color: col('muted'),
  align: edge.endsWith('left') ? 'left' : 'right', placement: { anchor: { to: 'container',
    edge }, offset: { x: mm(x), y: mm(12) } }, ...extra });
const folio = { fontWeight: 700, color: col('vermilion') };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  index,
  page: { sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5
    margins: { top: mm(23), bottom: mm(19), left: mm(18), right: mm(15), mirror: true } },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
    ruby: { fontSize: em(0.5) } }, // furigana on a term's first mention, in the 7 pt gap
  bodyText: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(PITCH), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, avoidRunts: true },
  headings: { fontFamily: GOTHIC, fontWeight: 700, color: col('ink'),
    balancing: { enabled: false }, // heads stay on the grid
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener(6) },
      { level: 2, numberingTemplate: '{1}.{2}', numberSeparator: ' ', fontSize: pt(10.5),
        lineSpan: 3 }, // 3行取り
    ] },
  headingStyles: [{ id: 'index', numbered: false, span: 'page', // the title over both columns
    breakBefore: { enabled: true, parity: 'any' },
    advancedDesign: opener(5, [band]), // two columns, two characters apart
    layout: { layoutType: 'double', gutterWidth: pt(2 * BODY) } }],
  paragraphStyles: [{ id: 'colophon', fontFamily: GOTHIC, fontSize: pt(6.5), lineHeight: pt(9),
    color: col('muted'), firstLineIndent: pt(0), textAlign: 'left', marginTop: pt(PITCH) }],
  header: { elements: [
    head('v-folio', '{pageNumber}', 'even', 'top-left', 0, folio),
    head('v-title', '{title}', 'even', 'top-left', 8),
    head('r-title', '{chapterTitle}', 'odd', 'top-right', -8),
    head('r-folio', '{pageNumber}', 'odd', 'top-right', 0, folio),
  ] },
  footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
    pages: 'opener', fontFamily: GOTHIC, fontSize: pt(7.5), fontWeight: 700,
    color: col('vermilion'), align: 'center', placement: { anchor: { to: 'container',
      edge: 'bottom' }, offset: { y: mm(-10) } } }] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 38行 · content.en.mdtitle: "組版の言葉" --- # 紙面を呼ぶ言葉 {kicker="第1章"} 本を作る現場には、紙面のあちこちを呼び分ける言葉がある。編集者と組版者とデザイナーが同じ言葉を使えば、指示は短く、間違いも少なくなる。本章では、日本語の本づくりで使われる基本的な用語を、紙面の名前、文字の大きさと書体、約物と組版の規則の三つに分けて紹介する。 ## 紙面の名前 ページのうち、本文が組まれる範囲を:index[{版面|はん|めん}]{main}という。版面の外側の余白には、それぞれ名前がある。上の余白が:index[{天|てん}]、下の余白が:index[{地|ち}]、綴じる側の余白が:index[のど]、その反対側が:index[{小口|こ|ぐち}]である。横組みの本では、のどは左ページの右側と右ページの左側にくる。 余白に置かれる書名や章の名前を:index[{柱|はしら}]{main}と呼び、ページの番号を:index[ノンブル]{main}と呼ぶ。ノンブルはフランス語のnombreから来た言葉で、印刷所ではいまも普通に使われている。:index{term="ノンブル" seealso="柱"}柱とノンブルは同じ行に並べることが多く、まとめて「柱」と呼ぶ人もいる。 ## 文字の大きさと書体 金属活字の時代、文字の大きさは:index[{号数|ごう|すう}]で呼ばれていた。本文に多く使われた五号は、いまの10.5ポイントに当たる。:index[ポイント]{main}は欧米から入った単位で、DTPでは1ポイントを72分の1インチ、約0.353ミリとする。 写真植字、略して:index[{写植|しゃ|しょく}]{main}の時代には、文字の大きさを:index[{級|きゅう}]、行の送りを:index[{歯|は}]で測った。どちらも0.25ミリを単位とし、13級は3.25ミリの大きさになる。:index{term="級" seealso="歯"}写植の文字盤は、1990年代に:index[DTP]{main}が広まると使われなくなったが、級と歯はいまもデザインの指定に残っている。:index{term="写植" seealso="DTP"} 書体では、縦の線が太く横の線が細い:index[明朝体]{yomi="みんちょうたい" main}が本文に、線の太さがそろった:index[ゴシック体]{yomi="ごしっくたい"}が見出しや注に使われる。明朝体の名は中国の明の時代の木版の文字に、ゴシック体の名は欧文のサンセリフを指した英語の呼び名に由来する。 ## 約物と組版の規則 句読点や括弧のように、文字以外の記号を:index[{約物|やく|もの}]{main}という。:index[{句読点|く|とう|てん}]の「、」と「。」は全角の幅を持ち、括弧と並ぶと間を詰めて組む。行の頭に「。」や「」」が来たり、行の末に「「」が残ったりしないように行の分け方を調整する処理を、:index[禁則処理]{yomi="きんそくしょり" main}という。日本語の組版の規則は、日本工業規格の:index[JIS X 4051]「日本語文書の組版方法」にまとめられ、W3Cの:index[JLReq]「日本語組版処理の要件」がその内容を英語でも紹介している。 漢字の読みを小さな仮名で示すものを:index[ルビ]{main}と呼ぶ。:index{term="振り仮名" yomi="ふりがな" see="ルビ"}ルビの名は、5.5ポイントの活字を英国でrubyと呼んだことに由来する。語を強調するために文字の横に打つ点は:index[{圏点|けん|てん}]{main}といい、:index{term="傍点" yomi="ぼうてん" see="圏点"}縦組みでは文字の右に、横組みでは上に置く。 縦組みの中で二桁の数字などを横に並べて一字分に収めるのが:index[{縦中横|たて|ちゅう|よこ}]、本文の行の中に小さな文字の注を二行に割って入れるのが:index[{割注|わり|ちゅう}]である。見出しを本文の何行分かの幅に収めて組むことを:index[{行取|ぎょう|ど}り]といい、3行取りの見出しなら、前後の本文の行の位置を変えずに済む。 これらの言葉の多くは、活字の時代から写植、DTPへと道具が変わっても使われ続けてきた。道具が変わっても、紙面の上で解くべき問題が同じだからである。 # 索引 {style="index" kicker="五十音順"} :::index :::paragraphs{style="colophon"} A specimen chapter written for the Postext Cookbook; the book is fictitious. The index files each term by its reading, taken from the furigana or from yomi. Set in Noto Serif JP and Noto Sans JP (SIL OFL). Text: CC BY 4.0. :::
`; // content.<lang>.md: the same Japanese chapter in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif JP': ['400', '700'], // 明朝: the text and the index, its main pages bold 'Noto Sans JP': ['400', '700'], // ゴシック: titles, heads, row heads, folios, colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each face loads the files of what it sets (gotcha: cjk-fonts-slices): the gothic the heads // and the row heads the index prints, あ行 to わ行 and A to Z. const all = (re) => (markdown.match(re) ?? []).join(''); const ROWS = 'あかさたなはまやらわ行ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; await loadFonts(FONTS, markdown); await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, `${markdown}→も見よ、`); await loadCjkFonts({ [GOTHIC]: FONTS[GOTHIC] }, `${all(/^#+ .*$/gm)}${all(/colophon"\}\n[^\n]*/g)}組版の言葉${ROWS}`); // Page 1 is page 9 of the book, a recto; the index follows the chapter. const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } }; const doc = await buildWithFonts(() => buildDocument({ markdown, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A Japanese index in gojūon order', es: 'Un índice japonés en orden gojūon' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
キット · core, fonts, viewer, pdf, cjk:全レシピ共通 · 453行// ─── 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 · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─ // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to buildWithFonts gets * the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files that * hold the characters its pages set (`request.codePoints`); any other * family gets the latin file fontsourceProvider fetches (the "pdf" block) * and, when the face sets letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); 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.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. A face Fontsource ships without latin-ext, or whose file does * not come, gets latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); 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.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); return ext ? [latin, ext] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] 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); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

アレンジ

#先頭のかなで見出しを立てる

'kana'は、辞書や長い人名索引のように、各グループの見出しに項目の先頭のかなを使います。か行ひとつの代わりに、か、き、くと見出しを立てます。

-  groupBy: 'gojuon', // what 'auto' picks in a 'ja' document: heads あ行 か行 さ行 …
+  groupBy: 'kana', // heads か き く け こ…

#本文で振り仮名を付けずに読みを与える

用語の初出には読みを付けず、それでも読みを与えるには、読みをyomiに移します。

-ページのうち、本文が組まれる範囲を:index[{版面|はん|めん}]{main}という。
+ページのうち、本文が組まれる範囲を:index[版面]{yomi="はんめん" main}という。

よくあるつまずき

つまずき

日本語のテキストのタグは'ja'。zh-HansやLANGにしない

レシピの版はenとesですが、日本語のサンプルはどちらの版でも日本語です。`locale: LANG`では英語やスペイン語とタグ付けされ、中国語のタグでは中国語の規則で組まれます(開明式の約物、小書きの仮名が行頭に来てもよい、図の代わりに图、PDFでは中国の字形)。'ja'と書いてください。日本の地域(JLReqの改行と約物、ゴマの圏点、ルビの間隔、図と表の表示名)が選ばれ、ハイフネーションが切られます。リントは、zhやkoのタグで仮名を含むテキストをエラーにします。 日本語の改行(禁則処理) →

つまずき

日本語は日本語の書体で組む

Noto Serif SCとTCは仮名を持っていますが、漢字は中国の字形(直、骨、角が異なります)で、仮名も中国のデザインで描きます。本文はNoto Serif JPかShippori Mincho B1、見出しはNoto Sans JPで組み、loadCjkFontsで読み込んでください。Fontsourceの日本語ファイルには変体仮名などの古い仮名(U+1B000–1B16F)がないため、loadCjkFontsはそこで失敗し、PDFには四角が印字されます。現代の仮名で書くか、それを持つ書体をレシピのassetsに同梱してください。Shippori Minchoにはマクロン付きの母音ōとūもないので、ローマ字はラテン文字の書体で組んでください。 仮名、漢字、ローマ字 →

つまずき

中国語の書体はcjkブロックを通じてスライス単位で読み込む

Fontsourceは中国語、日本語、韓国語のファミリーを、ウェイトごとに約100個のファイルとして配信し、各ファイルが文字の範囲を受け持ちます。loadFontsが取得するのはlatinファイルだけなので、画面では漢字がシステムの書体で出て計測が狂い、fontsourceProviderはPDFにそのlatinファイルを渡すため、漢字が空の四角で印字されます。キットのcjkブロックを挙げ、loadFontsのあとにloadCjkFonts(FONTS, markdown)を呼び(本で複数のCJK書体を使うときは、それぞれの書体で組むテキストを渡して書体ごとに1回)、renderToPdfにfontProvider: cjkPdfProviderを渡してください。どちらもテキストの文字を含むファイルを取得します。 中国語・日本語・韓国語のフォント →

つまずき

中国語にはイタリックがない

CJKのファミリーにはイタリックがなく、中国語の組版では強調を傾きではなく、文字の横の圏点で示します。中国語とタグ付けした文書では、*…*は中に含む漢字に圏点を付け、ラテン文字の語にはイタリックを使います(cjk.emphasis: 'dots'、中国語の既定)。:dots[…]はどこでも圏点を付けます。cjk.emphasis: 'italic'の場合や、enやesとタグ付けした文書では、*…*によってCanvasが立体のグリフを傾けます。中国語の組版では使わない擬似斜体です(PDFでは立体にフォールバックします)。中国語のタグを保つか、段落スタイルやチップスタイルで強調を太字や楷書体(LXGW WenKai)にしてください。 中国語・日本語・韓国語のフォント →

つまずき

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

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

つまずき

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

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

レイアウト警告 · indexReadingMissing

読みのない索引項目

原因. 日本語の索引は項目を読みで並べますが、漢字を確実に読めるアルゴリズムはありません。この項目には漢字が含まれ、どのマークも読み(yomi、マークしたテキストのかなのルビ、またはかなのソートキー)を与えていないため、かなの項目の後にコードポイント順で並びます。

対処. 項目のマークのどれかに、かなの読みを書いたyomi="…"を加える(:index[東京]{yomi="とうきょう"})か、ルビ付きのテキスト({東京|とう|きょう})をマークしてください。 ドキュメント →

  • 漢字を含み、読みのない用語はindexReadingMissingを出します。yomiがないと、明朝体は見出しのないまま索引の末尾、わ行のあとに落ち、組版時に、その項目は「has a kanji and no reading」という警告が出ました。
  • :index[…]の中の:ruby[…]は角かっこをエスケープする必要があります。マークは最初の]で終わるからです。簡潔な形、{版面|はん|めん}で書いてください。
  • ルビの読みが使われるのは、用語のすべての漢字を覆っているときだけです。:index[行{取|ど}り]では行の読みが欠けて警告が出ます。{行取|ぎょう|ど}りならぎょうどりになります。

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
フォント
Noto Serif JP (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF