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

レシピ集 · 第2章 · 文字と本文

28字×28行のグリッドに組む中国語の小説

魯迅の「故郷」を大32开のページに組みます。1行28字、1ページ28行、GBの禁則処理、开明式の約物、漢字と欧文のあいだのアキ。

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

70–71ページ · 1–2/7

  • 英語の見本:日本語版はまだありません
  • 仕上がり140 × 203 mm
  • 1段
  • Noto Serif SC 10.5/16.5
  • Ma Shan Zheng
  • Noto Sans SC
  • 7ページ
  • レベル
  • Postext 1.16.1
  • 組版時間14 ms
  • コード178行

かんたんな説明

魯迅の短編の冒頭を、中国のペーパーバックのページとして組みます。中国語の約物の規則に従いながら、各行にちょうど28字、各ページにちょうど28行を収める方法を示します。

できあがり

魯迅の「故郷」(故乡、1921年)の冒頭を、最初の作品集の本土版ペーパーバックが組むように組みます。140 × 203mmの大32开のページで、版面は字数で数え、1行28字、1ページ28行、Noto Serif SCの五号(10.5pt)です。段落はどれも2字下げで始まり、多くの本土の本と同じく、コンマや引用符は半字分の幅を取ります。行頭に。や,は来ず、行末に“は来ません。物語は奇数ページから、宋体のブラックウェイトで組んだ題の下で始まります。向かいのページには語り手が思い出す月夜のスイカ畑を描いた口絵があり、それを描写する文章がMa Shan Zhengの筆書きの書体で入ります。スペイン語を両端そろえで組むポケット判の小説は、同じことをラテン文字で行います。

このレシピが答える質問

  • 版面を字数で設定し、1行の字数と1ページの行数を決めるには?
  • 。や,が行頭に来ないようにするには?
  • 中国語の約物を、中国大陸の本のような開明式や半角で組むには?
  • 中国語と欧文の間のスペースを、入力せずに入れるには?

手短な答え

script.js · 37–53行コード全体で見る
const cjk = {
  // The type area in characters: 28 ems wide, 28 lines of LEAD tall. page.margins become
  // minimums, and the grid centres the type area in the room they leave.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  // What 'zh-Hans' gives by itself, written out so a reader can compare regions and styles.
  region: 'mainland',
  lineBreak: 'gb', // no 。,、”》 opens a line, no “《( ends one, and no / at either end
  punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
  compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
  latinSpacing: em(0.25), // 1921年, 用Noto Serif SC五号: a quarter em, none of it typed
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // spread between the characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // two characters, every paragraph
};

材料

種類
Noto Serif SC, Noto Sans SC, Ma Shan Zheng (SIL OFL 1.1)
素材
  • plate-1120.jpg
  • The plate: the moonlit melon field of the story, a painting (Generated With Diffusion Models, オリジナル)

作り方

#1 · 字数で数えるページ

コードは上の手短な答えにあります。cjk.gridは行長をbodyText.fontSizeの28em(294pt、103.7mm)、版面の高さをlineHeightの28行(462pt、163mm)とし、それに合わせて余白を動かします。そのため、漢字だけの行にはちょうど28字が入り、満杯のページはどれも同じ行で終わります(文字グリッド)。グリッドの下の5つの設定は、locale: 'zh-Hans'だけで得られる値です。1つずつ変えられるように書き出してありますが、compressAdjacentはpunctuationWidthが'fullwidth'になるまで何も変えません。开明式では、隣り合う約物が合わせて1.5emを超えることがないからです。71ページでは段落の最後の。が行に収まらず、行頭禁則('none'以外のすべてのlineBreakレベル。'gb'はスラッシュを加えます)が、それを行頭に置くことを禁じます。そこで行は事の後ろの。を半字幅にして取り込み(追い込み)、事。宏が詰まって、段落はもう1行に延びずにすみます(約物の幅)。字数の足りない行は単語のあいだではなく文字のあいだに割り振られ、このページでは0.05emを超えません(中国語・日本語・韓国語)。

71ページ、段落の最後の数行。事。宏を詰めて組み、看。が次の行頭に出ずにこの行にとどまっています。

#2 · 天を深くとった大32开のページ

script.js · 57–62行コード全体で見る
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // 22 + 18 mm leave 163 mm, exactly 28 lines of 16.5 pt; 18 mm a side leave the 103.7 mm
  // measure 0.3 mm to share. A grid that no longer fits is cut down (cjkGridClamped).
  margins: { top: mm(22), bottom: mm(18), left: mm(18), right: mm(18), mirror: true },
};

グリッドを有効にすると、余白は最小値になります。グリッドは余白が残す領域に版面を置き、余った分を割り振ります。22mmと18mmでちょうど163mmが残るので、中国の本と同じく天の余白は地より4mm深いままです。左右の18mmでは、割り振る余りは0.3mmです。収まる以上の字数や行数を求めると、グリッドは収まる分まで減らされ、cjkGridClamped警告が出ます。文字サイズと行送りは書いたとおりのままです。

#3 · 行数の整数倍だけ下げた題

script.js · 66–84行コード全体で見る
const SINK = 8; // lines of LEAD: a whole number, so the text under it keeps to the grid
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(SINK * LEAD),
  slot: { elements: [
    // The spacing after the last character is advance, not ink: the title stays centred.
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SONG, fontWeight: 900,
      fontSize: pt(46), letterSpacing: pt(23), lineHeight: 1.1, color: col('ink'),
      align: 'center', placement: centred(6) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.2),
      color: col('night'), placement: { ...centred(30), size: { width: mm(8) } } },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: HEI, fontSize: pt(10.5),
      letterSpacing: pt(5), color: col('ink'), align: 'center', placement: centred(34) },
  ] },
};
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener };

minHeightは16.5ptの8行分なので、題の下の本文はグリッドの9行目から始まり、他のすべてのページと同じベースラインに並びます。23ptの字間で、故と乡のあいだに2字の題によくある半字分のアキが入ります。Postextは行を中央にそろえるとき最後の字のあとの字間を除くので、題はページの軸からずれません(見出しスタイル)。

#4 · 偶数ページに書名、奇数ページに作品名

script.js · 88–107行コード全体で見る
const HEAD_Y = 11; // mm from the top edge to the heads; the hairline 5 mm lower
const label = { fontFamily: HEI, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } });
const header = { elements: [
  { kind: 'text', id: 'verso-folio', content: '{pageNumber}', parity: 'even', ...label,
    placement: at('top-left', SIDE) },
  { kind: 'text', id: 'verso-book', content: '{title}', parity: 'even', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-story', content: '{chapterTitle}', parity: 'odd', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-folio', content: '{pageNumber}', parity: 'odd', ...label,
    placement: at('top-right', -SIDE) },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    pages: 'body', placement: { ...at('top-left', SIDE, HEAD_Y + 5), size: { width: mm(MEASURE) } },
  },
] };
// The opener has no head: its folio drops to the foot, centred under the text.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...label, pages: 'opener', align: 'center', placement: at('bottom', 0, -10) }] };

本土の小説は偶数ページに書名、奇数ページに作品名を中央に置き、ノンブルを小口側に、どちらの下にも細い罫を引きます。pages: 'body'は柱を章扉と口絵から外し、章扉には代わりに地にノンブルが入ります。柱は黑体のNoto Sans SCで8ptです。小さくても、本文の宋体とは違う声として読めます。

#5 · 紙全体を覆う口絵

script.js · 111–143行コード全体で見る
// The plate, a painting: a JPEG in assets/ cut to the 140 × 203 mm trim, declared at its pixels.
const resources = [{
  id: 'moon', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'plate-1120.jpg', format: 'jpeg', width: 1120, height: 1624 },
  altText: t({
    en: 'A golden full moon in a deep blue sky over a strip of sea. On the sand below, among '
      + 'rows of striped watermelons, a boy with a silver collar stabs a steel fork at the '
      + 'sand ahead of him, and the small animal he aimed at runs off between his legs.',
    es: 'Una luna llena dorada en un cielo azul oscuro sobre una franja de mar. En la arena, '
      + 'entre hileras de sandías rayadas, un muchacho con un aro de plata al cuello clava una '
      + 'horquilla de acero en la arena, delante de él, y el animalillo al que apuntaba huye '
      + 'entre sus piernas.',
  }),
}];
const quote = { fontFamily: KAI, fontSize: pt(14), lineHeight: 1.5, color: col('paper') };
// # 月下的瓜地 {style="plate" line1="…" line2="…" line3="…"}: a page with no head or folio.
// 'page' spans the design over the sheet: a heading's design in the column is cut at its foot.
const plate = {
  id: 'plate', span: 'page', runningChapter: false, toc: false, // out of the PDF outline
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: 'moon', placement: { // the whole trim
      anchor: { to: 'bleed', edge: 'top-left' },
      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } },
    { kind: 'text', id: 'line1', content: '{attr.line1}', ...quote, align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(2) } } },
    { kind: 'text', id: 'line2', content: '{attr.line2}', ...quote, align: 'left',
      placement: { anchor: { to: '#line1', edge: 'below' } } },
    { kind: 'text', id: 'line3', content: '{attr.line3}', ...quote, align: 'left',
      placement: { anchor: { to: '#line2', edge: 'below' } } },
  ] } },
};

絵は専用の見出し# 月下的瓜地 {style="plate" …}に属し、そのスタイルがページを描いて柱とノンブルを消します。段の中にとどまる見出しのデザインは段の末尾、つまり仕上がりより18mm手前で切られるので、口絵にはspan: 'page'が必要です。runningChapter: falseとtoc: falseで口絵はPDFのしおりから外れ、しおりは故乡から始まります。引用の3行は、3つの属性と3つのテキスト要素で組みます。デザインのテキストは独自の改行処理を持ち、中国語の約物の幅を設定しないからです。手で改行した行は、中国語の表示用の行がふつうそうするように、読点も落としています。

#6 · 中国語の本文の中の欧文と数字

script.js · 147–153行コード全体で見る
const paragraphStyles = [
  // 小五 (9 pt) nudged to 9.1875 pt: 32 of its characters fill the 28-em measure exactly.
  { id: 'note', fontSize: pt((CHARS * BODY) / 32), lineHeight: pt(LEAD), color: col('ink'),
    firstLineIndent: em(2), marginTop: pt(LEAD) }, // on the grid, a line below the text
  { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];

76ページの注記と奥付は、数字や欧文の名前が漢字と出会う場所です。latinSpacingは、両者が接するところに4分アキを入れます。注記の1921年や奥付の用Noto Serif SC五号のように、スペースをまったく入れずに入力したところです(和欧間のアキ)。両端そろえの行ではこのアキが最初に広がり、最大で半字まで伸びます。注記は小五の9ptではなく9.1875ptで組み、32字で行長がちょうど埋まるようにしています。9ptでは各行に2/3字分の余りが出て、漢字と欧文のアキがそれを吸収していました。Markdownではフォント名をノーブレークスペース(U+00A0)でつないでいます。漢字と欧文のアキはこれに手を付けません。通常のスペースでは、奥付の行末不ぞろいの行がNoto Sans / SCで改行されていました。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 074 · A Chinese novel page on a 28 × 28 grid ════════════
// https://postext.dev/en/cookbook/chinese-novel-horizontal
// Code: MIT · Text: Lu Xun, 故乡 (1921), public domain, zh.wikisource · Plate: diffusion models
// Fonts: Noto Serif SC, Noto Sans SC, Ma Shan Zheng (SIL OFL 1.1) · Needs postext ≥ 1.9.0
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 = 'chinese-novel-horizontal';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// The night of the story's memory, and ink on a cream paper.
const palette = {
  ink: '#1f1c19', // text: a warm near-black
  night: '#1d3150', // the accent: the deep blue of the plate's sky, the opener's rule
  rule: '#c9c1b2', // the hairline under the running heads
  muted: '#6a645b', // running heads, folios, the colophon
  paper: '#fbf8f1', // a cream book paper
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'night (defaults)', value: { hex: palette.night, model: 'hex' } },
];
const SONG = 'Noto Serif SC'; // 宋: the text, and the title in its black weight
const HEI = 'Noto Sans SC'; // 黑: running heads, folios, the author, the colophon
const KAI = 'Ma Shan Zheng'; // 楷: a brush regular script for the plate's quotation
const [BODY, LEAD] = [10.5, 16.5]; // pt: 五号 on a 6 pt line gap, the grid's pitch
const [CHARS, LINES] = [28, 28];
const TRIM = { width: 140, height: 203 }; // mm: 大32开
const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号
const SIDE = (TRIM.width - MEASURE) / 2; // 18.1 mm: the grid centres the measure

// #region answer: 28 characters × 28 lines of 五号, mainland line breaks and Kaiming marks
const cjk = {
  // The type area in characters: 28 ems wide, 28 lines of LEAD tall. page.margins become
  // minimums, and the grid centres the type area in the room they leave.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  // What 'zh-Hans' gives by itself, written out so a reader can compare regions and styles.
  region: 'mainland',
  lineBreak: 'gb', // no 。,、”》 opens a line, no “《( ends one, and no / at either end
  punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
  compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
  latinSpacing: em(0.25), // 1921年, 用Noto Serif SC五号: a quarter em, none of it typed
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // spread between the characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // two characters, every paragraph
};
// #endregion

// #region page: 大32开 with a head margin larger than the foot
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // 22 + 18 mm leave 163 mm, exactly 28 lines of 16.5 pt; 18 mm a side leave the 103.7 mm
  // measure 0.3 mm to share. A grid that no longer fits is cut down (cjkGridClamped).
  margins: { top: mm(22), bottom: mm(18), left: mm(18), right: mm(18), mirror: true },
};
// #endregion

// #region opener: the title in the Song face's black weight, sunk 8 lines
const SINK = 8; // lines of LEAD: a whole number, so the text under it keeps to the grid
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(SINK * LEAD),
  slot: { elements: [
    // The spacing after the last character is advance, not ink: the title stays centred.
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SONG, fontWeight: 900,
      fontSize: pt(46), letterSpacing: pt(23), lineHeight: 1.1, color: col('ink'),
      align: 'center', placement: centred(6) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.2),
      color: col('night'), placement: { ...centred(30), size: { width: mm(8) } } },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: HEI, fontSize: pt(10.5),
      letterSpacing: pt(5), color: col('ink'), align: 'center', placement: centred(34) },
  ] },
};
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener };
// #endregion

// #region heads: the book on the verso, the story on the recto, a hairline under both
const HEAD_Y = 11; // mm from the top edge to the heads; the hairline 5 mm lower
const label = { fontFamily: HEI, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } });
const header = { elements: [
  { kind: 'text', id: 'verso-folio', content: '{pageNumber}', parity: 'even', ...label,
    placement: at('top-left', SIDE) },
  { kind: 'text', id: 'verso-book', content: '{title}', parity: 'even', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-story', content: '{chapterTitle}', parity: 'odd', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-folio', content: '{pageNumber}', parity: 'odd', ...label,
    placement: at('top-right', -SIDE) },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    pages: 'body', placement: { ...at('top-left', SIDE, HEAD_Y + 5), size: { width: mm(MEASURE) } },
  },
] };
// The opener has no head: its folio drops to the foot, centred under the text.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...label, pages: 'opener', align: 'center', placement: at('bottom', 0, -10) }] };
// #endregion

// #region plate: the moonlit melon field faces the opener, the quotation set in Kai
// The plate, a painting: a JPEG in assets/ cut to the 140 × 203 mm trim, declared at its pixels.
const resources = [{
  id: 'moon', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'plate-1120.jpg', format: 'jpeg', width: 1120, height: 1624 },
  altText: t({
    en: 'A golden full moon in a deep blue sky over a strip of sea. On the sand below, among '
      + 'rows of striped watermelons, a boy with a silver collar stabs a steel fork at the '
      + 'sand ahead of him, and the small animal he aimed at runs off between his legs.',
    es: 'Una luna llena dorada en un cielo azul oscuro sobre una franja de mar. En la arena, '
      + 'entre hileras de sandías rayadas, un muchacho con un aro de plata al cuello clava una '
      + 'horquilla de acero en la arena, delante de él, y el animalillo al que apuntaba huye '
      + 'entre sus piernas.',
  }),
}];
const quote = { fontFamily: KAI, fontSize: pt(14), lineHeight: 1.5, color: col('paper') };
// # 月下的瓜地 {style="plate" line1="…" line2="…" line3="…"}: a page with no head or folio.
// 'page' spans the design over the sheet: a heading's design in the column is cut at its foot.
const plate = {
  id: 'plate', span: 'page', runningChapter: false, toc: false, // out of the PDF outline
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: 'moon', placement: { // the whole trim
      anchor: { to: 'bleed', edge: 'top-left' },
      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } },
    { kind: 'text', id: 'line1', content: '{attr.line1}', ...quote, align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(2) } } },
    { kind: 'text', id: 'line2', content: '{attr.line2}', ...quote, align: 'left',
      placement: { anchor: { to: '#line1', edge: 'below' } } },
    { kind: 'text', id: 'line3', content: '{attr.line3}', ...quote, align: 'left',
      placement: { anchor: { to: '#line2', edge: 'below' } } },
  ] } },
};
// #endregion

// #region styles: the editor's note in 小五 Song, the colophon in Hei
const paragraphStyles = [
  // 小五 (9 pt) nudged to 9.1875 pt: 32 of its characters fill the 28-em measure exactly.
  { id: 'note', fontSize: pt((CHARS * BODY) / 32), lineHeight: pt(LEAD), color: col('ink'),
    firstLineIndent: em(2), marginTop: pt(LEAD) }, // on the grid, a line below the text
  { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hans', // written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette, page, layout: { layoutType: 'single' }, cjk, bodyText,
  // The designs paint the titles; weight 400 keeps the heading blocks in the loaded face.
  headings: { fontFamily: SONG, fontWeight: 400, levels: [chapter] },
  headingStyles: [plate], paragraphStyles, header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 141行 · content.en.mdtitle: "呐喊" author: "鲁迅" --- # 月下的瓜地 {style="plate" line1="深蓝的天空中挂着一轮金黄的圆月" line2="下面是海边的沙地" line3="都种着一望无际的碧绿的西瓜"} # 故乡 我冒了严寒,回到相隔二千余里,别了二十余年的故乡去。 时候既然是深冬;渐近故乡时,天气又阴晦了,冷风吹进船舱中,呜呜的响,从篷隙向外一望,苍黄的天底下,远近横着几个萧索的荒村,没有一些活气。我的心禁不住悲凉起来了。 阿!这不是我二十年来时时记得的故乡? 我所记得的故乡全不如此。我的故乡好得多了。但要我记起他的美丽,说出他的佳处来,却又没有影像,没有言辞了。仿佛也就如此。于是我自己解释说:故乡本也如此,——虽然没有进步,也未必有如我所感的悲凉,这只是我自己心情的改变罢了,因为我这次回乡,本没有什么好心绪。 我这次是专为了别他而来的。我们多年聚族而居的老屋,已经公同卖给别姓了,交屋的期限,只在本年,所以必须赶在正月初一以前,永别了熟识的老屋,而且远离了熟识的故乡,搬家到我在谋食的异地去。 第二日清早晨我到了我家的门口了。瓦楞上许多枯草的断茎当风抖着,正在说明这老屋难免易主的原因。几房的本家大约已经搬走了,所以很寂静。我到了自家的房外,我的母亲早已迎着出来了,接着便飞出了八岁的侄儿宏儿。 我的母亲很高兴,但也藏着许多凄凉的神情,教我坐下,歇息,喝茶,且不谈搬家的事。宏儿没有见过我,远远的对面站着只是看。 但我们终于谈到搬家的事。我说外间的寓所已经租定了,又买了几件家具,此外须将家里所有的木器卖去,再去增添。母亲也说好,而且行李也略已齐集,木器不便搬运的,也小半卖去了,只是收不起钱来。 “你休息一两天,去拜望亲戚本家一回,我们便可以走了。”母亲说。 “是的。” “还有闰土,他每到我家来时,总问起你,很想见你一回面。我已经将你到家的大约日期通知他,他也许就要来了。” 这时候,我的脑里忽然闪出一幅神异的图画来:深蓝的天空中挂着一轮金黄的圆月,下面是海边的沙地,都种着一望无际的碧绿的西瓜,其间有一个十一二岁的少年,项带银圈,手捏一柄钢叉,向一匹猹尽力的刺去,那猹却将身一扭,反从他的胯下逃走了。 这少年便是闰土。我认识他时,也不过十多岁,离现在将有三十年了;那时我的父亲还在世,家景也好,我正是一个少爷。那一年,我家是一件大祭祀的值年。这祭祀,说是三十多年才能轮到一回,所以很郑重;正月里供祖像,供品很多,祭器很讲究,拜的人也很多,祭器也很要防偷去。我家只有一个忙月(我们这里给人做工的分三种:整年给一定人家做工的叫长年;按日给人做工的叫短工;自己也种地,只在过年过节以及收租时候来给一定的人家做工的称忙月),忙不过来,他便对父亲说,可以叫他的儿子闰土来管祭器的。 我的父亲允许了;我也很高兴,因为我早听到闰土这名字,而且知道他和我仿佛年纪,闰月生的,五行缺土,所以他的父亲叫他闰土。他是能装弶捉小鸟雀的。 我于是日日盼望新年,新年到,闰土也就到了。好容易到了年末,有一日,母亲告诉我,闰土来了,我便飞跑的去看。他正在厨房里,紫色的圆脸,头戴一顶小毡帽,颈上套一个明晃晃的银项圈,这可见他的父亲十分爱他,怕他死去,所以在神佛面前许下愿心,用圈子将他套住了。他见人很怕羞,只是不怕我,没有旁人的时候,便和我说话,于是不到半日,我们便熟识了。 我们那时候不知道谈些什么,只记得闰土很高兴,说是上城之后,见了许多没有见过的东西。 第二日,我便要他捕鸟。他说: “这不能。须大雪下了才好。我们沙地上,下了雪,我扫出一块空地来,用短棒支起一个大竹匾,撒下秕谷,看鸟雀来吃时,我远远地将缚在棒上的绳子只一拉,那鸟雀就罩在竹匾下了。什么都有:稻鸡、角鸡、鹁鸪、蓝背……” 我于是又很盼望下雪。 闰土又对我说: “现在太冷,你夏天到我们这里来。我们日里到海边检贝壳去,红的绿的都有,鬼见怕也有,观音手也有,晚上我和爹管西瓜去,你也去。” “管贼么?” “不是。走路的人口渴了摘一个瓜吃,我们这里是不算偷的。要管的是獾猪、刺猬、猹。月亮地下,你听,啦啦的响了,猹在咬瓜了。你便捏了胡叉,轻轻地走去……” 我那时并不知道这所谓猹的是怎么一件东西——便是现在也没有知道——只是无端的觉得状如小狗而很凶猛。 “他不咬人么?” “有胡叉呢。走到了,看见猹了,你便刺。这畜生很伶俐,倒向你奔来,反从胯下窜了。他的皮毛是油一般的滑。……” 我素不知道天下有这许多新鲜事:海边有如许五色的贝壳;西瓜有这样危险的经历,我先前单知道他在水果店里出卖罢了。 “我们沙地里,潮汛要来的时候,就有许多跳鱼儿只是跳,都有青蛙似的两个脚。……” 阿!闰土的心里有无穷无尽的希奇的事,都是我往常的朋友所不知道的。他们不知道一些事,闰土在海边时,他们都和我一样只看见院子里高墙上的四角的天空。 可惜正月过去了,闰土须回家里去,我急得大哭,他也躲到厨房里,哭着不肯出门,但终于被他父亲带走了。他后来还托他的父亲带给我一包贝壳和几枝很好看的鸟毛,我也曾送他一两次东西,但从此没有再见面。 现在我的母亲提起了他,我这儿时的记忆,忽而全都闪电似的苏生过来,似乎看到了我的美丽的故乡了。我应声说: “这好极!他,——怎样?……” “他?……他景况也很不如意……”母亲说着,便向房外看,“这些人又来了。说是买木器,顺手也就随便拿走的,我得去看看。” 母亲站起身,出去了。门外有几个女人的声音,我便招宏儿走近面前,和他闲话:问他可会写字,可愿意出门。 “我们坐火车去么?” “我们坐火车去。” “船呢?” “先坐船,……” “哈!这模样了!胡子这么长了!”一种尖利的怪声突然大叫起来。 我吃了一吓,赶忙抬起头,却见一个凸颧骨,薄嘴唇,五十岁上下的女人站在我面前,两手搭在髀间,没有系裙,张着两脚,正像一个画图仪器里细脚伶仃的圆规。 我愕然了。 “不认识了么?我还抱你咧!” 我愈加愕然了。幸而我的母亲也就进来,从旁说: “他多年出门,统忘却了。你该记得罢,”便向着我说,“这是斜对门的杨二嫂,……开豆腐店的。” 哦,我记得了。我孩子时候,在斜对门的豆腐店里确乎终日坐着一个杨二嫂,人都叫伊“豆腐西施”。但是擦着白粉,颧骨没有这么高,嘴唇也没有这么薄。而且终日坐着,我也从没有见过这圆规式的姿势。那时人说:因为伊,这豆腐店的买卖非常好。但这大约因为年龄的关系,我却并未蒙着一毫感化,所以竟完全忘却了。然而圆规很不平,显出鄙夷的神色,仿佛嗤笑法国人不知道拿破仑,美国人不知道华盛顿似的,冷笑说: “忘了?这真是贵人眼高。……” “那有这事……我……”我惶恐着,站起来说。 “那么,我对你说。迅哥儿,你阔了,搬动又笨重,你还要什么这些破烂木器,让我拿去罢。我们小户人家,用得着。” “我并没有阔哩。我须卖了这些,再去……” “阿呀呀,你放了道台了,还说不阔?你现在有三房姨太太;出门便是八抬的大轿,还说不阔?吓,什么都瞒不过我。” 我知道无话可说了,便闭了口,默默的站着。 “阿呀阿呀,真是愈有钱,便愈是一毫不肯放松,愈是一毫不肯放松,便愈有钱……”圆规一面愤愤的回转身,一面絮絮的说,慢慢向外走,顺便将我母亲的一副手套塞在裤腰里,出去了。 此后又有近处的本家和亲戚来访问我。我一面应酬,偷空便收拾些行李,这样的过了三四天。 一日是天气很冷的午后,我吃过午饭,坐着喝茶,觉得外面有人进来了,便回头去看。我看时,不由的非常出惊,慌忙站起身,迎着走去。 这来的便是闰土。虽然我一见便知道是闰土,但又不是我这记忆上的闰土了。他身材增加了一倍;先前的紫色的圆脸,已经变作灰黄,而且加上了很深的皱纹;眼睛也像他父亲一样,周围都肿得通红,这我知道,在海边种地的人,终日吹着海风,大抵是这样的。他头上是一顶破毡帽,身上只一件极薄的棉衣,浑身瑟索着;手里提着一个纸包和一枝长烟管,那手也不是我所记得的红活圆实的手,却又粗又笨而且开裂,像是松树皮了。 我这时很兴奋,但不知道怎么说才好,只是说: “阿!闰土哥,——你来了?……” 我接着便有许多话,想要连珠一般涌出:角鸡、跳鱼儿、贝壳、猹,……但又总觉得被什么挡着似的。单在脑里面回旋,吐不出口外去。 他站住了,脸上现出欢喜和凄凉的神情;动着嘴唇,却没有作声。他的态度终于恭敬起来了,分明的叫道: “老爷!……” 我似乎打了一个寒噤;我就知道,我们之间已经隔了一层可悲的厚障壁了。我也说不出话。 :::paragraphs{style="note"} (本篇作于1921年1月,同年5月1日发表于《新青年》第9卷第1号,1923年8月收入小说集《呐喊》。这里节选前半篇,至闰土来访为止。文字据1948年版《鲁迅全集》第一卷,改排为简体横排:引号『』改作“”,“馀”“偸”改作“余”“偷”。) ::: :::paragraphs{style="colophon"} 本书正文用Noto Serif SC五号字排,每行28字,每面28行;书眉、页码和版本说明用Noto Sans SC,篇首题句用Ma Shan Zheng,均为SIL OFL字体。篇首插图由程序绘制。 ::: :::paragraphs{style="colophon"} Lu Xun, 故乡 (My Old Home), 1921: the first half of the story · Text: public domain, from zh.wikisource · Plate: a painting. :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif SC': ['400', '900'], // SONG: the text; the title 'Noto Sans SC': ['400'], // HEI: running heads, folios, the author, the colophon 'Ma Shan Zheng': ['400'], // KAI: the plate's quotation }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each voice loads the files of the characters it sets (gotcha: cjk-fonts-slices). const part = (re) => markdown.match(re)?.[0] ?? ''; const colophon = part(/:::paragraphs\{style="colophon"\}[\s\S]*$/); await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, markdown); await loadCjkFonts({ [SONG]: ['900'] }, '故乡'); await loadCjkFonts({ [HEI]: ['400'] }, `呐喊故乡鲁迅0123456789${colophon}`); await loadCjkFonts({ [KAI]: ['400'] }, part(/^# .*style="plate".*$/m)); await loadImage(resources[0].bitmap.fileId, asset(resources[0].bitmap.fileId)); // The plate is page 70 of the book, a verso: folios and parity follow the book. const continuation = { pageIndexOffset: 69, pageNumbering: { startAt: 70 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A Chinese novel page on a 28 × 28 grid', es: 'Una página de novela china en una retícula de 28 × 28' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
キット · core, fonts, viewer, pdf, images, cjk:全レシピ共通 · 488行// ─── 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 · 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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#すべての約物を全角にする(全角式)

コンマ(,)、読点(、)、引用符が1字分を取り、。”は2字分を取るので、各行に入る本文の字数が減ります。

-  punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
-  compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
+  punctuationWidth: 'fullwidth',
+  compressAdjacent: false,

#ページを組むあいだグリッドを表示する

Canvasは各行のすべての字の位置に薄い灰色の正方形を描きます(稿纸)。PDFでは、renderToPdfにcharacterGrid: trueを渡さないかぎり描かれません。

-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },

よくあるつまずき

つまずき

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

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

つまずき

文書のタグはLANGではなくzh-Hansかzh-Hantにする

レシピの版はenとesですが、中国語のサンプルはどちらの版でも中国語です。`locale: LANG`では英語やスペイン語とタグ付けされ、ラテン文字の語がハイフネーションされ、図にFigureやFiguraの表示名が付き、PDFの言語も誤ります。タグは自分で書いてください。'zh-Hans'(中国本土の慣習:GBの改行規則、開明式の約物)または'zh-Hant'(台湾:全角で中央に置く約物)、香港なら'zh-HK'です。単なる'zh'は簡体字、本土として読まれます。日本語のサンプルには'ja'を使います(つまずき「ja-locale-tag」)。 中国語の改行 →

つまずき

各地域の約物はその地域の書体で組む

約物の幅の処理は、各記号の横の空きを、フォントが描く側ではなく、文書の地域が置く側に移します。zh-Hansでは開明式の読点は箱の左半分を残し(簡体字の書体はそこにグリフを描きます)、右半分を手放します。繁体字の書体は,。を箱の中央に描くため、手放す半分にグリフの一部があり、読点が次の文字に食い込みます。Fontsourceで唯一の楷書の本文書体であるLXGW WenKai TCは、簡体字のテキストでこうなります。本土のテキスト、注、引用はNoto Serif SCかNoto Sans SCで組み、TCの書体はzh-Hantに取っておき、簡体字の毛筆書体(Ma Shan Zheng)は表示用の行だけに使ってください。デザインのテキストには約物の幅の処理がかからないためです。LXGW WenKai TCは、本土の規格も台湾の規格も使わない伝承字形も描きます。爲の爫の頭を持つ為や、卩の足を持つ令(冷、領の中も)です。この書体で組むページの文字を確認してください。 中国語の約物の幅 →

つまずき

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

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 }を書き直してください。 奇数ページから始まる章 →

つまずき

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

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

つまずき

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

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

レイアウト警告 · cjkGridClamped

大きすぎる文字グリッド

原因. `cjk.grid`が求める1行の字数またはページあたりの行数が、本文のサイズと行の高さではページの余白の内側に収まらないため、収まる最大の値でグリッドを組みます。

対処. `charsPerLine`または`linesPerPage`を下げる、余白を狭める(余白は最小値です)、または`bodyText.fontSize`や`lineHeight`を小さくしてください。 ドキュメント →

  • 口絵は見出しで、見出しにはPDFのしおりが付きます。口絵のスタイルにrunningChapter: falseとtoc: falseがなければ、しおりは故乡より前に置かれた第1レベルのしおり、月下的瓜地から始まります。どちらか一方の設定だけでも口絵はしおりから外れます。タグ付きPDFでは、口絵の題はH1として残ります。
  • デザインがすべての題を描く場合でも、見出し自体の書体はFONTSが読み込むものにしてください。既定のウェイト700では、キットはレイアウトの中にNoto Serif SC 700を見つけ、そのラテン文字のファイルを警告付きで読み込みました。headings.fontWeight: 400で、見出しブロックは本文の書体のままになります。

クレジット

レシピ
Ignacio Ferro
本文
  • “My Old Home” (故乡, 1921), the first half, from the 1948 Complete Works, in simplified characters · Lu Xun (鲁迅) · パブリックドメイン
  • The editor’s note and the colophon · Postext Cookbook · オリジナル
画像
  • The plate: the moonlit melon field of the story, a painting · Generated With Diffusion Models · オリジナル
フォント
Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · Ma Shan Zheng (SIL OFL 1.1)
SandboxPDF