# كتاب قراءة بالبينيين: النطق فوق كل حرف

> روبي أحادي في كتاب قراءة أول من هونغ كونغ: {人之初|rén zhī chū} يضع مقطع بينيين واحدًا في الوسط فوق كل حرف، بخط Andika، داخل فجوة أسطر من 28 نقطة.

- نسخة HTML: https://postext.dev/ar/cookbook/pinyin-primer
- وصفة رقم 076 · الحروف والنص · المستوى 2 (متوسط) · المخرجات: Canvas
- الأنواع: الكتب المدرسية, كتب التمارين والتدريبات
- تتطلب postext ≥ 1.9.0 · اختُبرت مع 1.9.2 بتاريخ 2026-10-01
- الصفحات: [36](https://postext.dev/cookbook/pinyin-primer/en/p01.webp?v=5a5533d5), [37](https://postext.dev/cookbook/pinyin-primer/en/p02.webp?v=5a5533d5), [38](https://postext.dev/cookbook/pinyin-primer/en/p03.webp?v=5a5533d5), [39](https://postext.dev/cookbook/pinyin-primer/en/p04.webp?v=5a5533d5)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=pinyin-primer&lang=en (.postext: https://postext.dev/cookbook/pinyin-primer/en/pinyin-primer.postext)
- آخر تحديث: 2026-09-29
- لغات أخرى: [en](https://postext.dev/en/cookbook/pinyin-primer.md), [es](https://postext.dev/es/cookbook/pinyin-primer.md), [ca](https://postext.dev/ca/cookbook/pinyin-primer.md), [zh](https://postext.dev/zh/cookbook/pinyin-primer.md)

## باختصار

صفحات من كتاب قراءة أول للأطفال في هونغ كونغ. يبيّن كيف يُطبع النطق بالحروف اللاتينية فوق كل حرف صيني، مع مربعات للتدرّب على الكتابة.

## ما الذي ستنضده

صفحتان متقابلتان مرتين من 《蒙學誦讀》، كتاب قراءة أول متخيَّل من هونغ كونغ، حيث يقرأ الأطفال *Three Character Classic* (كتاب الحروف الثلاثة) بصوت عالٍ بلغة البوتونغهوا. كل صفحة درس من أربعة أبيات مزدوجة بخط Kai بحجم 一号 (26 نقطة)، وكل حرف تحت مقطع البينيين الخاص به. تُنضَّد المقاطع بخط Andika لأنه يرسم الحرفين a وg بطابق واحد كما تطبعهما الكتب المدرسية الصينية. ويحمل شريط شاحب شارة درس حمراء، ولوحة مائية صغيرة، والعنوان بحجم 初号 (42 نقطة) بنطقه الخاص. وتحت النص ستة مربعات كتابة (田字格) فيها الحروف المطلوب نسخها، وملاحظة تشرح للأسرة معنى الأبيات. ونظيره الإسباني هو [كتاب القراءة بشارات المقاطع](https://postext.dev/ar/cookbook/reading-primer-syllables.md)، حيث كل مقطع شارة ملوّنة بدل نطق فوق حرف.

يخرج كتاب القراءة الأول عن قواعد الكتاب الصيني عن قصد. يقرأ الطفل حروفًا كبيرة قليلة في كل مرة، فيحمل السطر اثني عشر حرفًا بحجم 26 نقطة، حيث ينضّد الكتاب من 25 إلى 40 بحجم 10.5 نقطة، ويزيد تباعد الأسطر قليلًا على ضعف الحجم ليتسع للنطق. ويقف كل بيت مزدوج في وسط سطر خاص به، لا مضبوطًا ولا بمسافة بادئة. والنص بخط Kai، الخط الذي يتبع الفرشاة، لأنه يُظهر الضربات التي يتعلم الطفل كتابتها؛ أما الكتاب فكان سينضّده بخط Song.

**تجيب هذه الوصفة عن:**

- كيف أطبع البينيين فوق كل حرف من نص صيني، كما يفعل كتاب القراءة الأول؟

## الجواب المختصر

```js
// script.js, سطرًا 36–56
// {人之初|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
};
```

## المكونات

**تعلّم**

- [قراءات ruby: pinyin وzhuyin](https://postext.dev/ar/docs/document-format.md#العلامات-الصينية-والقراءات-ruby-والتعليقات-السطرية-warichu): قراءات توضع فوق المحارف التي تشرحها أو بجانبها، بالتوجيه :ruby[…]{rt="…"} أو بالصيغة المختصرة {人之初|rén zhī chū}: قراءة لكل محرف، فيجوز أن ينقسم السطر بينها، أو قراءة واحدة لكلمة كاملة. يوضع pinyin فوق النص الأفقي وzhuyin على يمين كل محرف؛ ويحدّد cjk.ruby خطها وحجمها ولونها وجهتها، وتشغل القراءات الفراغ بين الأسطر، الذي يجب أن يكون واسعًا بما يكفي لاحتوائها.
- [الخطوط الصينية واليابانية والكورية](https://postext.dev/ar/docs/configuration.md#خطوط-الصينية-واليابانية-والكورية): خطوط CJK تُحمَّل بأجزاء Fontsource التي يمسّها النص، على الشاشة وفي PDF، الذي يضمّن كل جزء مجموعةً جزئية ويبلّغ عن أي محرف لا يحويه أي ملف.

**تستخدم أيضًا**

- [شبكة المحارف](https://postext.dev/ar/docs/configuration.md#شبكة-الحروف)
- [عروض علامات الترقيم الصينية](https://postext.dev/ar/docs/configuration.md#عروض-علامات-الترقيم)
- [خط المتن](https://postext.dev/ar/docs/configuration.md#نص-المتن)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [الصور في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#عناصر-الصورة)
- [النصوص والخطوط والمربعات في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [الترويسات وأرقام الصفحات](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [تقسيم الأسطر في النص الصيني](https://postext.dev/ar/docs/configuration.md#تنضيد-نصوص-شرق-آسيا)
- [الخروج عن الشبكة عمدًا](https://postext.dev/ar/docs/architecture.md#العناصر-الكاسرة-للشبكة)
- [شريط فصل بعرض الصفحة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [أنماط الفقرات](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format.md#الموارد)

**الإعدادات في لمحة**

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`cjk`](https://postext.dev/ar/docs/configuration.md#تنضيد-نصوص-شرق-آسيا), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`headings`](https://postext.dev/ar/docs/configuration.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration.md#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration.md#تقسيم-الكلمات-بالواصلة), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)

**الخطوط**

- LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1), Andika (OFL-1.1)

## طريقة التحضير

### 1 · مقطع فوق كل حرف

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. يعطي `{人之初|rén zhī chū}` ثلاثة حروف ثلاثة أنطاق، مقسومة عند المسافات: كل مقطع في الوسط فوق حرفه، ويجوز أن ينكسر السطر بينها. لا يأخذ النطق مكانًا خاصًا به. إنه يجلس في فجوة الأسطر، 54 − 26 = 28 نقطة هنا، والفجوة الأضيق من النطق يُبلَّغ عنها بـ`rubyExceedsLeading`. أما المقطع الأعرض من حرفه فيأخذ مكانًا: يوسّع مربع ذلك الحرف، وفي سطر موسَّط تنزاح الحروف التالية عن الشبكة. لذا نُضّدت الأنطاق بالحجم الذي يظل فيه أعرض المقاطع هنا، xiāng وzhuān، يتسع فوق حرف واحد، أي 0.38 em (9.9 نقطة): كل بيت مزدوج طوله ثمانية حروف ويحتفظ كل حرف بمربعه. وبحجم 0.45 em كان 性相近，習相遠 سيتمدد إلى ثمانية حروف ونصف ويفتح فجوات حول 相.

### 2 · العنوان يحتفظ بنطقه

```js
// script.js, سطرًا 60–77
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) } } },
] } };
```

نص التصميم لا يطبع أنطاقًا، فلا يمكن أن يأتي العنوان من تصميم صفحة الافتتاح. عنوان المستوى الأول، 第一課، يرسم الشريط والصورة والشارة الحمراء؛ أما العنوان فهو عنوان المستوى الثاني تحته، عنوان عادي بحجم 初号 ينضّده منضِّد النص مع البينيين الخاص به. ويُخرج `reserve: false` الشريط والصورة من ارتفاع العنوان، ويرسمهما `span: 'page'` تحت النص.

تصميم العنوان يتجاهل `parity`، فللصورة عنصر على كل جانب من الصفحة: `{attr.left}` على بعد 14 مم من الحافة اليسرى و`{attr.right}` على بعد 14 مم من اليمنى. تكتب الدروس التي على الصفحات الزوجية `# 第一課 {left="sprout"}` والتي على الصفحات الفردية `{right="shuttle"}`، فتجلس كل صورة على الجانب الخارجي من الصفحتين المتقابلتين؛ والعنصر الذي تغيب سمته لا يرسم شيئًا.

### 3 · ستة مربعات من سمة واحدة

```js
// script.js, سطرًا 81–101
// 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="人之本不相以"}` التصميمَ الحروفَ الستة في سمة واحدة. كل حرف هاني في LXGW WenKai TC عرضه em واحد، فتتبّعٌ قدره خطوة المربعات ناقص em واحد (18.4 − 10.6 مم) ينقل الحروف من مربع إلى مربع، ويملأ عنصر نصي واحد الصف. والمربع ملف SVG مرسوم بالشيفرة: إطار أحمر وصليب متقطع.

### 4 · كل خط يحمّل حروفه

```js
// script.js, سطرًا 283–290
// 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 كل خط صيني إلى نحو مئة ملف بحسب نطاقات الحروف. خط Kai ينضّد النموذج كله ويحمّل الملفات التي فيها حروفه. وخط Hei لا ينضّد إلا الشارات والتسميات والتذييل، فيأخذ ذلك النص ويجلب ملفات قليلة. ويأتي Andika من `loadFonts`، الذي يضيف ملف latin-ext حين يحتوي النص حروفًا خارج Latin-1، كما هو حال ǎ وǐ.

### 5 · ترقيم هونغ كونغ من الإعداد المحلي

```js
// script.js, سطرًا 121–123
  // 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 فواصله ونقاطه في وسط المربع، كما تطبعها هونغ كونغ وتايوان، ولهذا يأتي كتاب القراءة هذا من هونغ كونغ. أما كتاب القراءة الأول في البر الصيني فيُنضَّد بخط Kai أيضًا، بالحروف المبسّطة مع الفاصلة والنقطة في أسفل يسار المربع. غير أن Fontsource ليس فيه خط نص Kai للصينية المبسّطة، والخط التقليدي كان سيحشر تلك العلامات بالحرف التالي، لذا تأخذ صفحة البر الصيني في دليل الوصفات هذا خط Noto Serif SC، خط Song في [صفحة الرواية من البر الصيني](https://postext.dev/ar/cookbook/chinese-novel-horizontal.md).

## الوصفة كاملة

ملف واحد، مركّب من مجلد الوصفة ومعه نص المثال وأدوات دليل الوصفات المشتركة مضمّنة؛ وهو ينشئ صفحته بنفسه. لتشغيله، ضعه داخل `<script type="module">` في صفحة فارغة أو الصقه في لوحة JS في مثال CodePen جديد (وحدةً). يستورد postext من esm.sh، فلا حاجة إلى تثبيت أو بناء.

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/pinyin-primer

### script.js

```js
// ═══ 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`---
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' }) });

// ─── 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 goes to fontsourceProvider (the "pdf" block). */
async function cjkPdfProvider(family, weight, style, request) {
  if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style);
  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()));
  }));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### أظهر الشبكة في أثناء تنضيد الصفحة

يرسم `show: true` المربعات الاثني عشر لكل سطر على اللوحة (Canvas)؛ ويتركها ملف PDF ما لم يُعطَ `renderToPdf` الخيار `characterGrid: true`.

```diff
-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11, show: true },
```

### ضع الجويين بجوار الحروف

أنطاق البوبوموفو تقف في عمود على يمين كل حرف؛ وينضّدها [كتاب القراءة بالجويين](https://postext.dev/ar/cookbook/zhuyin-vertical-reader.md) نزولًا في صفحة عمودية.

## أخطاء شائعة

- **القراءات تُطبع في النص، لا في التصاميم ولا في التعليقات ولا في الخلايا.** تُرسَم قراءات ruby في الفقرات والعناوين وبنود القوائم والاقتباسات والإطارات. أما عنصر نص التصميم (افتتاحية، ترويسة، شارة)، والتعليق، والحاشية السفلية، وخلية الجدول، فتطبع الحروف الأساسية دون قراءاتها. العنوان الذي يحتاج إلى pinyin الخاص به عنوانٌ بلا تصميم خاص به: ارسم ما يحيط به (شريط، رقم الدرس) بتصميم العنوان الذي قبله، الذي تُرسَم عناصره ذات reserve: false تحت نص افتتاحية span: 'page'.
- **ضع ترقيم كل منطقة بخط من تلك المنطقة.** تنقل عروض علامات الترقيم الفراغ الذي بجانب كل علامة إلى الجهة التي تضعه فيها منطقة المستند، لا الجهة التي يرسمه فيها الخط: مع zh-Hans تحتفظ فاصلة Kaiming بالنصف الأيسر من إطارها، حيث يرسم الخط المبسط الحرف، وتتخلى عن النصف الأيمن. أما الخط التقليدي فيضع ，。 في وسط الإطار، فيحمل النصف المحذوف جزءًا من الحرف وتزاحم الفاصلةُ الحرفَ التالي. وهذا ما يفعله LXGW WenKai TC، وهو خط Kai النصي الوحيد في Fontsource، في النص المبسط. ضع نص البر الرئيسي وحواشيه واقتباساته بـ Noto Serif SC أو Noto Sans SC، واحتفظ بخطوط TC للنص zh-Hant، ولا تستخدم خط فرشاة مبسطًا (Ma Shan Zheng) إلا لأسطر العرض، حيث لا يطبّق نص التصميم عروض الترقيم. كما يرسم ذلك الخط أشكالًا موروثة لا يطبعها معيار البر الرئيسي ولا معيار تايوان، 為 برأس 爫 الذي في 爲، و令 (في 冷 و領) بقاعدة 卩: افحص الحروف التي تضعها الصفحة به.
- **الخطوط الصينية تُحمَّل شرائح، عبر الكتلة cjk.** يقدّم Fontsource العائلة الصينية أو اليابانية أو الكورية في نحو مئة ملف لكل وزن، يغطي كل منها نطاقًا من الحروف. لا يجلب loadFonts إلا ملف latin، فتأتي حروف الهان على الشاشة من خط النظام وتُقاس خطأً، ويسلّم fontsourceProvider ملف latin ذاك إلى PDF، فيطبعها مربعات فارغة. اذكر كتلة الأدوات cjk، واستدعِ loadCjkFonts(FONTS, markdown) بعد loadFonts (مرة لكل صوت، مع النص الذي يضعه، حين يستخدم الكتاب عدة خطوط CJK)، وأعطِ renderToPdf القيمة fontProvider: cjkPdfProvider: كلاهما يأخذ الملفات التي تحتوي حروف النص.
- **اوسم المستند بـ zh-Hans أو zh-Hant، لا بـ LANG.** نسختا الوصفة هما en وes، لكن المثال الصيني صيني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، فيقسّم كلماته اللاتينية بالواصلة، ويسمّي أشكاله Figure أو Figura، ويعطي PDF لغة خاطئة. اكتب الوسم بنفسك: 'zh-Hans' (أعراف البر الرئيسي: كسر الأسطر وفق GB، وترقيم Kaiming) أو 'zh-Hant' (تايوان: ترقيم بعرض كامل متمركز)؛ و'zh-HK' لهونغ كونغ. أما 'zh' المجرّد فيُقرأ صينية مبسطة على أعراف البر الرئيسي.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب.** يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق.
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **تحذير إخراج: القراءات تزاحم السطر التالي** (`rubyExceedsLeading`). فقرة فيها قراءات روبي فوق نصها أو تحته، وفراغها بين الأسطر أضيق من القراءات: فهي تقع في تباعد الأسطر وتلامس السطر المجاور. الحل: انضد الفقرة بتباعد أسطر أكبر (حجم النص مضافًا إليه `cjk.ruby.fontSize` على الأقل)، أو صغّر القراءات. ([التوثيق](https://postext.dev/ar/docs/configuration.md#العلامات-والروبي-والواريتشو))

- لا تقدّم هذه الوصفة ملف PDF. فـ`fontsourceProvider` في العُدّة لا يضمّن إلا ملف latin للخط، وحروف النغمات ā ǎ ǐ ǒ ǔ في latin-ext، فكان ملف PDF سيفقدها وكان postext-pdf سيبلّغ عن كلٍّ منها بـ`missingGlyph`. إن احتجت إليه، فانضّد الأنطاق بخط صيني، يختار `cjkPdfProvider` ملفاته حرفًا حرفًا.

- طابق كل حرف في المربعات مع الأشكال المعيارية للمنطقة. يرسم LXGW WenKai TC الحرف 為 بأعلى 爫 من 爲، شكلٌ أقدم يمرّ في النص الجاري، كما في 老何為 و為人子 هنا، لكن لا في مربع ينسخه طفل في هونغ كونغ. لذا يتدرّب الدرس الثالث على 幼 بدلًا منه.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: 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 ([المصدر](https://zh.wikisource.org/w/index.php?title=%E6%96%B0%E5%88%8A%E4%B8%89%E5%AD%97%E7%B6%93&oldid=10344699)), ملكية عامة
- النص: 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 (OFL-1.1), Noto Sans TC (OFL-1.1), Andika (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

## وصفات ذات صلة

- [رقم 060 · كتاب قراءة مبكرة بشارات المقاطع](https://postext.dev/ar/cookbook/reading-primer-syllables.md): وحدة الحرف M من كتاب قراءة إسباني: المقاطع شارات بلون حرفها الصوتي، وشبكة كلمات مصوّرة في كل خلية منها لوحة مائية. · المستوى 2 (متوسط) · كتب التمارين والتدريبات
- [رقم 080 · كتاب قراءة عمودي بالزهويين على اليمين](https://postext.dev/ar/cookbook/zhuyin-vertical-reader.md): كتاب قراءة مدرسي من تايوان منضد نزولًا: يضع {守株|ㄕㄡˇ|ㄓㄨ} عمود زهويين يمين كل حرف، والصور والحواشي في طبقة عليا. · المستوى 3 (متقدم) · الكتب المدرسية
- [رقم 081 · تواريخ واختصارات قائمة في النص العمودي](https://postext.dev/ar/cookbook/chinese-dates-upright.md): وثيقتان تأسيسيتان من 1912 منضدتان عموديًا بتجليد على اليمين: الأعداد ذات الرقمين تقوم وحدها في خانة واحدة، ويعلّم :tcy و:upright الباقي. · المستوى 2 (متوسط) · الكتب المدرسية, الأوراق المفردة والمطبوعات العابرة
