かんたんな説明
香港の子ども向け入門読本のページです。すべての漢字の上に発音をラテン文字で印刷する方法と、書き取り練習用のマス目の作り方がわかります。
できあがり
『蒙學誦讀』という架空の香港の入門読本の見開き2つです。香港の子どもたちは『三字経』を普通話で音読します。各ページは1課で、対句4つを一号(26 pt)の楷書体で組み、すべての漢字の上にピンインの音節を載せます。音節はAndikaで組みます。中国の教科書が印刷する1階建てのaとgを描く書体だからです。淡い帯には赤い課のバッジ、小さな水彩画、初号(42 pt)のタイトルが載り、タイトルにも読みが付きます。本文の下には書き写す漢字を入れた6つの書き取りのマス目(田字格)があり、注記がその句の意味を家庭に説明します。スペイン語で対になるのは音節チップを使う入門読本で、そちらでは音節ごとに、漢字の上の読みではなく色付きのチップを使います。
入門読本は、あえて中国の本の決まりから外れます。子どもは大きな字を少しずつ読むので、1行は26 ptで12字です。本なら10.5 ptで25〜40字を組みます。行送りは、読みを収めるために文字サイズの2倍を少し超えます。対句はそれぞれ独立した行に中央そろえで置き、両端そろえも字下げもしません。本文は筆の運びに沿う楷書体で組みます。子どもが書けるようになる筆画が見えるからです。本なら宋体で組むところです。
このレシピが答える質問
- 漢字の上にピンインを付けるには?
手短な答え
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
// The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
ruby: {
fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
// 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
// over one character, so every couplet is 8 em long and keeps to the grid.
fontSize: em(0.38),
color: col('pinyin'),
},
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};
材料
- 機能
- ルビ:ピンインと注音中国語・日本語・韓国語のフォント文字グリッド中国語の約物の幅本文の書体デザインした章扉見出しの属性ページデザインの画像ページデザインのテキスト・罫・ボックス囲みセマンティックカラーパレット柱とノンブル
- 種類
- LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1)
- 素材
brush-800.jpgjade-800.jpgshuttle-800.jpgsprout-800.jpg- The writing squares, drawn in code in the page’s palette (Postext Cookbook, CC BY 4.0)
- Lesson 1’s vignette: a seedling, a watercolour (Generated With Diffusion Models, オリジナル)
- Lesson 2’s vignette: the shuttle of a loom, a watercolour (Generated With Diffusion Models, オリジナル)
- Lesson 3’s vignette: a brush and its first stroke, a watercolour (Generated With Diffusion Models, オリジナル)
- Lesson 4’s vignette: a jade disc on a red cord, a watercolour (Generated With Diffusion Models, オリジナル)
作り方
#1 · 漢字1字ごとに音節を1つ
コードは上の手短な答えにあります。{人之初|rén zhī chū}は3字に3つの読みを与え、スペースで区切ります。各音節は自分の漢字の上の中央に置かれ、音節の間では改行できます。読みは自分の場所を取りません。行間に収まり、ここでは54 − 26 = 28 ptです。読みより狭い行間はrubyExceedsLeadingとして報告されます。漢字より幅の広い音節は場所を取ります。その漢字のボックスを広げ、中央そろえの行では後に続く漢字がグリッドからずれます。読みは、ここでいちばん幅の広い音節であるxiāngとzhuānがまだ1字の上に収まる大きさ、0.38 em(9.9 pt)で組んでいます。これで対句はどれも8字分の長さになり、各字が自分のマスを保ちます。0.45 emでは、性相近,習相遠が8字半に伸び、相の前後に隙間が空きます。
#2 · タイトルも読みを保つ
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
resourceId: `{attr.${side}}`, decorative: true, reserve: false,
placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
{ kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: mm(184), height: mm(BAND) } } },
picture('left', 14), picture('right', -14),
{ kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top' } },
box: { backgroundColor: col('red'), borderRadius: mm(3.5),
padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };
デザインテキストは読みを印刷しないので、タイトルを章扉のデザインから出すことはできません。レベル1見出しの第一課が帯、絵、赤いバッジを描き、タイトルはその下のレベル2見出しです。これは初号の普通の見出しで、本文の組版処理がピンイン付きで組みます。reserve: falseは帯と絵を見出しの高さから外し、span: 'page'はそれらを本文の下に塗ります。
見出しのデザインはparityを無視するので、絵にはページの左右それぞれに要素を用意します。左端から14 mmの{attr.left}と、右端から14 mmの{attr.right}です。偶数ページの課は# 第一課 {left="sprout"}、奇数ページの課は{right="shuttle"}と書くので、どの絵も見開きの外側に来ます。属性のない側の要素は何も描きません。
#3 · 1つの属性から6つのマス目
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
size: { width: mm(SQ), height: mm(SQ) } } })),
{ kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
verticalAlign: 'middle', overflow: 'clip',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };
### 我會寫 {write="人之本不相以"}は、デザインに6つの漢字を1つの属性で渡します。LXGW WenKai TCの漢字はどれも1 em幅なので、トラッキングをマス目のピッチから1 emを引いた値(18.4 − 10.6 mm)にすると、漢字がマスからマスへ進み、1つのテキスト要素で1列が埋まります。マス目はコードで描いたSVGで、赤い枠と破線の十字です。
#4 · 書体ごとに自分の文字を読み込む
// Fontsource cuts a Chinese face into about a hundred files by character range
// (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings'
// labels and the footer's series line, so it fetches a few files.
const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊';
await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ
await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown),
loadCjkFonts({ [HEI]: FONTS[HEI] }, labels),
loadSvg('tian.svg', tian), ...VIGNETTES.map((fileId) => loadImage(fileId, asset(fileId)))]);
Fontsourceは中国語の書体を、文字の範囲ごとに約100のファイルに分けています。楷書体は見本全体を組むので、その文字を含むファイルを読み込みます。黒体はバッジ、ラベル、フッターだけを組むので、そのテキストを渡され、数ファイルだけを取得します。AndikaはloadFontsから読み込みます。この関数は、テキストにǎやǐのようなLatin-1の範囲を超える文字があると、latin-extのファイルを加えます。
#5 · ロケールで香港の約物にする
// Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
// it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
locale: 'zh-HK',
zh-HKは香港の規則を設定します。約物は全角、改行規則は基本のもので、ここの対句はどれも改行規則を必要としません。LXGW WenKai TCは、香港や台湾の印刷と同じく、読点と句点をマスの中央に描きます。この入門読本を香港のものにしたのはそのためです。中国大陸の入門読本も楷書体で組みますが、文字は簡体字で、読点と句点はマスの左下に置きます。しかしFontsourceには簡体字中国語の楷書体の本文用書体がなく、繁体字用の書体ではそれらの約物が次の字に詰まってしまうので、このレシピ集の大陸向けのページは、大陸の小説のページの宋体であるNoto Serif SCを使います。
レシピの全体
// ═══ Postext Cookbook · Nº 076 · A pinyin primer: readings over every character ═══ // https://postext.dev/en/cookbook/pinyin-primer // Code: MIT · Text: 三字經 (PD); pinyin, notes: CC BY 4.0 · Vignettes: diffusion models // Fonts: LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1) · Needs postext ≥ 1.9.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'pinyin-primer'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a primer's colours, every one linked by id const palette = { ink: '#29241f', // the characters: a warm near-black pinyin: '#355a4d', // the readings, a shade off the ink so the two layers part red: '#bf3a2b', // lesson badges and the writing squares jade: '#2f7a5e', // the folio discs tint: '#edf4ea', // the band behind each lesson's title cream: '#faf3e4', // the note for families muted: '#6d665e', // series line, colophon paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the red. const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [KAI, HEI, PINYIN] = ['LXGW WenKai TC', 'Noto Sans TC', 'Andika']; const TEXT = 26; // pt: 一号, the size of a first reader's text const LINE = 54; // pt: 2.1 × the size, so a reading fits between two lines const CHARS = 12; // characters per line: the measure is 12 × 26 pt = 110 mm const MEASURE = CHARS * TEXT * 25.4 / 72; // mm // #region answer: one reading per character, in Andika, in a line gap wide enough to hold it // {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for // three characters, split on the spaces. The reading sits in the line gap, centred on its // character; a syllable wider than the character widens that character's box by what the // reading needs, less the quarter of the reading's size it may lend a neighbour. const cjk = { // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it. grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 }, ruby: { fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit // over one character, so every couplet is 8 em long and keeps to the grid. fontSize: em(0.38), color: col('pinyin'), }, }; // The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must // hold it, or the build warns rubyExceedsLeading. const text = { fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE), textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred }; // #endregion // #region opener: a tinted band with the lesson's badge and its drawing const BAND = 68; // mm from the top edge // Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer // edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing. const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`, resourceId: `{attr.${side}}`, decorative: true, reserve: false, placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) }, size: { width: mm(42), height: mm(42) } } }); const opener = { enabled: true, slot: { elements: [ { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(184), height: mm(BAND) } } }, picture('left', 14), picture('right', -14), { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11), fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top' } }, box: { backgroundColor: col('red'), borderRadius: mm(3.5), padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } }, ] } }; // #endregion // #region squares: six writing squares (田字格) with the lesson's characters to copy // Design text prints no readings, so the squares hold the characters alone. A Han character // is one em wide, so tracking of (pitch − em) sets one in the middle of each square. const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt const ROW = 6 * SQ + 5 * GAP; // mm const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide const squares = { enabled: true, slot: { elements: [ { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10), fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } }, ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian', decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) }, size: { width: mm(SQ), height: mm(SQ) } } })), { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE), lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left', verticalAlign: 'middle', overflow: 'clip', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) }, size: { width: mm(ROW + GAP), height: mm(SQ) } } }, ] } }; // #endregion // The page number in a jade disc at the outer foot, the series beside it. const DISC = 8; // mm const at = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-10) } }); const folio = (parity, edge, x, sign) => [ { kind: 'text', id: `n-${parity}`, parity, content: '{pageNumber}', fontFamily: PINYIN, fontSize: pt(10), fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle', overflow: 'clip', placement: { ...at(edge, x), size: { width: mm(DISC), height: mm(DISC) } }, box: { backgroundColor: col('jade'), borderRadius: mm(DISC / 2) } }, { kind: 'text', id: `s-${parity}`, parity, content: '{title} 第一冊', fontFamily: HEI, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'), align: sign > 0 ? 'left' : 'right', verticalAlign: 'middle', overflow: 'clip', placement: { ...at(edge, x + sign * (DISC + 3)), size: { width: mm(60), height: mm(DISC) } } }, ]; const config = () => ({ // a factory: the engine caches resolved configs per object // #region locale: Hong Kong's rules, the ones the Kai face is drawn for // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag). locale: 'zh-HK', // #endregion colorPalette, page: { sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开 // Minimums, the head deeper than the foot (天头 over 地脚): cjk.grid adds what the // 12 × 11 type area (110 × 210 mm) leaves, 3.2 mm to each, so 27.2 over 23.2 mm. margins: { top: mm(24), bottom: mm(20), left: mm(18), right: mm(18), mirror: true } }, layout: { layoutType: 'single' }, cjk, bodyText: { ...text, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink') }, // the palette does not reach referenceColor headings: { fontFamily: KAI, color: col('ink'), fontWeight: 400, textAlign: 'center', snapToGrid: false, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // Every lesson opens a page; span 'page' paints the band under the text. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginBottom: mm(4), advancedDesign: opener }, // The lesson's title: a plain heading, so its readings print (初号, 42 pt). { level: 2, fontSize: pt(42), lineHeight: pt(76), marginTop: pt(0), marginBottom: mm(6) }, { level: 3, marginTop: mm(7), marginBottom: mm(6), advancedDesign: squares }, ] }, paragraphStyles: [ { id: 'colophon', fontFamily: PINYIN, fontSize: pt(7), lineHeight: pt(9), color: col('muted'), textAlign: 'center', marginTop: mm(3) }, ], calloutStyles: [ { id: 'family', background: col('cream'), borderRadius: mm(3), snapToGrid: false, marginTop: mm(0), marginBottom: mm(0), padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) }, titleStyle: { fontFamily: PINYIN, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('red') }, body: { fontFamily: PINYIN, fontSize: pt(9.5), lineHeight: pt(13), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true } }, ], header: { elements: [] }, footer: { elements: [...folio('even', 'bottom-left', 20, 1), ...folio('odd', 'bottom-right', -20, -1)] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 86行 · content.en.md
title: "蒙學誦讀" --- # 第一課 {left="sprout"} ## {人之初|rén zhī chū} {人之初|rén zhī chū},{性本善|xìng běn shàn}。 {性相近|xìng xiāng jìn},{習相遠|xí xiāng yuǎn}。 {苟不教|gǒu bú jiào},{性乃遷|xìng nǎi qiān}。 {教之道|jiào zhī dào},{貴以專|guì yǐ zhuān}。 ### 我會寫 {write="人之本不相以"} :::callout{type="family" title="For families"} People are good when they are born. Their natures are much the same; their habits carry them apart. Left untaught, a nature drifts, and teaching works when it keeps at one thing. Read each line aloud together, one syllable to each character, pointing to the character as you say it. The marks over the vowels are the four tones: ā level, á rising, ǎ dipping, à falling. ::: # 第二課 {right="shuttle"} ## {昔孟母|xī mèng mǔ} {昔孟母|xī mèng mǔ},{擇鄰處|zé lín chǔ}。 {子不學|zǐ bù xué},{斷機杼|duàn jī zhù}。 {竇燕山|dòu yān shān},{有義方|yǒu yì fāng}。 {教五子|jiào wǔ zǐ},{名俱揚|míng jù yáng}。 ### 我會寫 {write="子母山五方名"} :::callout{type="family" title="For families"} Long ago, Mencius’s mother moved house to find good neighbours, and when her son skipped his lessons she cut the cloth on her loom. Dou Yanshan had the right method: he taught his five sons, and all five made their names. Mencius (Mèngzǐ, about 372–289 BC) is honoured as the Second Sage, after Confucius. Dou Yanshan, a tenth-century official, saw his five sons pass the imperial examinations. ::: # 第三課 {left="brush"} ## {養不教|yǎng bú jiào} {養不教|yǎng bú jiào},{父之過|fù zhī guò}。 {教不嚴|jiào bù yán},{師之惰|shī zhī duò}。 {子不學|zǐ bù xué},{非所宜|fēi suǒ yí}。 {幼不學|yòu bù xué},{老何為|lǎo hé wéi}? ### 我會寫 {write="父師學幼老何"} :::callout{type="family" title="For families"} To raise a child without teaching is the father’s fault; to teach without strictness is the teacher’s neglect. A child who does not study is not doing right: who does not learn when young, what will he do when old? The word bù, “not”, is said bú before a fourth tone, so the first line reads yǎng bú jiào. The book prints the tone you say. ::: # 第四課 {right="jade"} ## {玉不琢|yù bù zhuó} {玉不琢|yù bù zhuó},{不成器|bù chéng qì}。 {人不學|rén bù xué},{不知義|bù zhī yì}。 {為人子|wéi rén zǐ},{方少時|fāng shào shí}。 {親師友|qīn shī yǒu},{習禮儀|xí lǐ yí}。 ### 我會寫 {write="玉成知方友禮"} :::callout{type="family" title="For families"} Jade that is not carved does not become a vessel; a person who does not learn does not know what is right. While still young, a child keeps close to teachers and friends and learns good manners. Practise the six characters in the squares: first in the air with a finger, then with a pencil, stroke by stroke. ::: :::paragraphs{style="colophon"} Set in LXGW WenKai TC, Noto Sans TC and Andika (SIL OFL) · Text: the Three Character Classic (13th century), zh.wikisource · Pinyin and notes: Postext Cookbook, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the writing square in code, the lessons' vignettes as watercolours // The square: a red frame and a dashed cross, the guide for placing strokes. const P = palette; const mix = (a, b, t) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t).toString(16).padStart(2, '0')).join('')}`; const tian = '<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" ' + 'viewBox="0 0 15 15"><path d="M7.5 .4V14.6M.4 7.5H14.6" fill="none" stroke-width=".18" ' + `stroke="${mix(P.red, P.paper, 0.55)}" stroke-dasharray=".7 .55"/><rect x=".2" y=".2" ` + `width="14.6" height="14.6" fill="none" stroke="${P.red}" stroke-width=".35"/></svg>`; // The vignettes: 人之初 a seedling, 昔孟母 the loom's shuttle, 養不教 a brush's first stroke, // 玉不琢 a jade disc. Painted on white and multiplied by the band's tint, so they sit on it. const VIGNETTES = ['sprout-800.jpg', 'shuttle-800.jpg', 'brush-800.jpg', 'jade-800.jpg']; const artwork = [{ id: 'tian', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'tian.svg', width: 150, height: 150 } }, ...VIGNETTES.map((fileId) => ({ id: fileId.split('-')[0], typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: 800, height: 800 } }))]; // at their pixels // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build (gotcha: fonts-first). const FONTS = { 'LXGW WenKai TC': ['400'], // 楷: the text and the titles 'Noto Sans TC': ['700'], // 黑: badges, labels, the series line Andika: ['400', '700'], // the pinyin, the notes, the folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each Chinese face loads the files of the characters it sets // Fontsource cuts a Chinese face into about a hundred files by character range // (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings' // labels and the footer's series line, so it fetches a few files. const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊'; await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown), loadCjkFonts({ [HEI]: FONTS[HEI] }, labels), loadSvg('tian.svg', tian), ...VIGNETTES.map((fileId) => loadImage(fileId, asset(fileId)))]); // #endregion // Page 1 is page 36 of the primer: a verso, so the four lessons lie as two spreads. const continuation = { pageIndexOffset: 35, pageNumbering: { startAt: 36 } }; const doc = await buildWithFonts(() => buildDocument({ markdown, resources: artwork, continuation }, config()), markdown); showBook(doc, { title: t({ en: 'A pinyin primer', es: 'Una cartilla con pinyin' }) });キット · core, fonts, viewer, images, cjk:全レシピ共通 · 448行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · 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上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#組んでいる間はグリッドを表示する
show: trueは各行の12個のマスをcanvasに描きます。PDFでは、renderToPdfにcharacterGrid: trueを渡さない限り描かれません。
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11, show: true },#注音を漢字の横に置く
注音符号の読みは各漢字の右に縦に並びます。注音付きの読本は、それを縦組みのページに組んでいます。
よくあるつまずき
つまずき
ルビは本文に出て、デザイン、キャプション、セルには出ない
ルビは段落、見出し、リスト項目、引用、囲みで描かれます。デザインのテキスト要素(章扉、柱、バッジ)、キャプション、脚注、表のセルでは、ルビなしで親文字だけが印字されます。ピンインが必要なタイトルは、独自のデザインを持たない見出しにしてください。その周り(帯や課の番号)は直前の見出しのデザインで描きます。reserve: falseの要素は、span: 'page'の章扉の本文の下に描かれます。 ルビ:ピンインと注音 →
つまずき
各地域の約物はその地域の書体で組む
約物の幅の処理は、各記号の横の空きを、フォントが描く側ではなく、文書の地域が置く側に移します。zh-Hansでは開明式の読点は箱の左半分を残し(簡体字の書体はそこにグリフを描きます)、右半分を手放します。繁体字の書体は,。を箱の中央に描くため、手放す半分にグリフの一部があり、読点が次の文字に食い込みます。Fontsourceで唯一の楷書の本文書体であるLXGW WenKai TCは、簡体字のテキストでこうなります。本土のテキスト、注、引用はNoto Serif SCかNoto Sans SCで組み、TCの書体はzh-Hantに取っておき、簡体字の毛筆書体(Ma Shan Zheng)は表示用の行だけに使ってください。デザインのテキストには約物の幅の処理がかからないためです。LXGW WenKai TCは、本土の規格も台湾の規格も使わない伝承字形も描きます。爲の爫の頭を持つ為や、卩の足を持つ令(冷、領の中も)です。この書体で組むページの文字を確認してください。 中国語の約物の幅 →
つまずき
中国語の書体は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」)。 中国語の改行 →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
レイアウト警告 · rubyExceedsLeading
ルビが次の行に接触
原因. 本文の上または下にルビを付けた段落で、行間がルビより狭くなっています。ルビは行間に置かれるため、隣の行に接してしまいます。
対処. 段落の行送りを大きくする(少なくとも文字サイズに`cjk.ruby.fontSize`を加えた値)か、ルビを小さくしてください。 ドキュメント →
-
このレシピにはPDFがありません。キットの
fontsourceProviderは書体のlatinファイルしか埋め込まず、声調付きの文字ā ǎ ǐ ǒ ǔはlatin-extにあるので、PDFではそれらが失われ、postext-pdfがそれぞれをmissingGlyphとして報告します。PDFが必要なら、読みを中国語の書体で組んでください。中国語の書体のファイルは、cjkPdfProviderが1字ずつ選びます。 -
マス目の各字が、その地域の標準字形かどうかを確かめてください。LXGW WenKai TCは為を爲の爫の形の上部で描きます。古い字形で、ここの老何為や為人子のように本文中なら通りますが、香港の子どもが書き写すマス目には向きません。第3課では代わりに幼を練習します。
クレジット
- 本文
- The Three Character Classic (三字經), lines 1–32: the text of the Harvard-Yenching Library’s 新刊三字經 as transcribed on zh.wikisource (revision 10344699), with today’s punctuation and 隣 written 鄰 · Traditionally attributed to Wang Yinglin (13th century); transcription by Wikisource editors · パブリックドメイン
- The pinyin, the notes for families and the translations · Postext Cookbook · CC BY 4.0
- 画像
- The writing squares, drawn in code in the page’s palette · Postext Cookbook · CC BY 4.0
- Lesson 1’s vignette: a seedling, a watercolour · Generated With Diffusion Models · オリジナル
- Lesson 2’s vignette: the shuttle of a loom, a watercolour · Generated With Diffusion Models · オリジナル
- Lesson 3’s vignette: a brush and its first stroke, a watercolour · Generated With Diffusion Models · オリジナル
- Lesson 4’s vignette: a jade disc on a red cord, a watercolour · Generated With Diffusion Models · オリジナル
- フォント
- LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Andika (SIL OFL 1.1)


