# علامات أسماء العلم وعناوين الكتب، عموديًا وأفقيًا

> السيرة الأساسية لشيانغ يو مع علامات :name و:book، منضّدة في صفحة عمودية مجلّدة من اليمين ثم في صفحة أفقية: الخطوط يسار الأسماء، ثم تحتها.

- نسخة HTML: https://postext.dev/ar/cookbook/proper-name-marks
- وصفة رقم 077 · الحروف والنص · المستوى 2 (متوسط) · المخرجات: Canvas, PDF
- الأنواع: الكتب المدرسية
- تتطلب postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · اختُبرت مع 1.9.0, postext-pdf 1.9.0 بتاريخ 2026-09-30
- الصفحات: [一](https://postext.dev/cookbook/proper-name-marks/en/p01.webp?v=7e6ae7b9), [二](https://postext.dev/cookbook/proper-name-marks/en/p02.webp?v=7e6ae7b9), [三](https://postext.dev/cookbook/proper-name-marks/en/p03.webp?v=7e6ae7b9)
- PDF: https://postext.dev/cookbook/proper-name-marks/en/proper-name-marks.pdf?v=7e6ae7b9
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=proper-name-marks&lang=en (.postext: https://postext.dev/cookbook/proper-name-marks/en/proper-name-marks.postext)
- آخر تحديث: 2026-09-30
- لغات أخرى: [en](https://postext.dev/en/cookbook/proper-name-marks.md), [es](https://postext.dev/es/cookbook/proper-name-marks.md), [ca](https://postext.dev/ca/cookbook/proper-name-marks.md), [zh](https://postext.dev/zh/cookbook/proper-name-marks.md)

## باختصار

صفحات نموذجية من كتاب تاريخ صيني كلاسيكي. تبيّن كيف تميّز الأسماء بخط أحمر مستقيم وعناوين الكتب بخط متموّج، في النص العمودي والأفقي معًا.

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

ثلاث صفحات نموذجية لكتاب مختارات من *Records of the Grand Historian* (سجلات المؤرّخ الكبير)، على قياس القص 140 × 203 mm الخاص بالكتاب الصيني 大32開. المقطع هو مطلع السيرة الأساسية لشيانغ يو بالترقيم الكامل لطبعة كلاسيكية: خط مستقيم بجانب كل شخص ومكان ودولة وسلالة، وخط متموّج بجانب كل عنوان كتاب أو فصل، وكلاهما بالأحمر القرمزي. تنضّد الصفحة 1 المقدمة وأعراف المحرّر بخط Kai. وتنضّد الصفحتان 2 و3 مصدر Markdown نفسه مرتين، عموديًا ثم أفقيًا، فتُظهر الصفحتان المتقابلتان أين تقع العلامات: يسار الأسماء في الأسطر العمودية، وتحتها في الأفقية. الكتاب مجلّد من اليمين، ويحمل شريط نيلي كل عنوان. أما [الوصفة رقم 044، Lycidas بنص 1645](https://postext.dev/ar/cookbook/critical-edition-line-numbers.md) فتميّز أسماءها على الطريقة الأوروبية، بالخط المائل.

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

- كيف أعلّم أسماء الأعلام وعناوين الكتب في النص الصيني بخط مستقيم وخط متموّج؟
- لماذا يُطبع التوكيد الصيني عندي بوجه مائل، وماذا أستعمل بدلًا منه؟
- كيف أنضد كتابًا صينيًّا عموديًّا، مجلَّدًا من اليمين؟

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

```js
// script.js, سطرًا 45–62
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
```

## المكونات

**تعلّم**

- [نقاط التوكيد وخطوط الأعلام والعناوين](https://postext.dev/ar/docs/document-format.md#العلامات-الصينية-والقراءات-ruby-والتعليقات-السطرية-warichu): العلامات التي يضعها الصينيون بجانب المحارف بدل المائل: نقاط التوكيد (着重号، :dots[…])، والخط المستقيم لأسماء الأعلام (专名号، :name[…])، والخط المتموّج لعناوين الكتب (书名号، :book[…])، تحت النص في الكتابة الأفقية وبجانبه في الكتابة العمودية؛ ويطبع cjk.bookTitleMark العناوين بين 《》 أو بخط متموّج أو بلا شيء، ويلوّن cjk.annotationColor العلامات.
- [النص العمودي](https://postext.dev/ar/docs/configuration.md#الكتابة-العمودية): صينية ويابانية تُصفّ من الأعلى إلى الأسفل في أسطر تُقرأ بدءًا من اليمين (layout.writingMode 'vertical-rl')، والأعمدة طبقات، والأشكال والجداول قائمة، وعلامات الترقيم بأشكالها العمودية.

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

- [كتب مجلّدة من اليمين](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#خطوط-الصينية-واليابانية-والكورية)
- [الخطوط المضمَّنة في PDF](https://postext.dev/ar/docs/configuration.md#لماذا-مزوّد-الخطوط)
- [التصدير إلى PDF](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-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/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/document-format.md#numbering)
- [مسافة عمودية صريحة](https://postext.dev/ar/docs/document-format.md#space)

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

- [`bodyText`](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#رؤوس-الصفحات-وتذييلاتها), [`headingStyles`](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#تقسيم-الكلمات-بالواصلة), [`orderedLists`](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#ذاكرة-القياس-المؤقتة), [`decompressWoff2`](https://postext.dev/ar/docs/configuration.md#مزوّد-الخطوط-في-المتصفح-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية), [`renderToPdf`](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-pdf)

**الخطوط**

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

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

### 1 · الترميز يضع العلامة، ونمط الكتابة يحدّد مكانها

الكود هو [الجواب المختصر](#الجواب-المختصر) أعلاه. يُبقي `:name[項梁]` و`:book[史記]` حروفهما في النص، فيقرأ البحث والنص المنسوخ وملف PDF 項梁 و史記، ويضيفان العلامات بجانبها ([العلامات الصينية والروبي والواريتشو](/ar/docs/document-format#العلامات-الصينية-والقراءات-ruby-والتعليقات-السطرية-warichu)). يحدّد نمط الكتابة وحده الجانب: يضع `layout.writingMode: 'vertical-rl'` الخطوط يسار الحروف، ويضعها أسلوب العنوان `across`، الذي تخطيطه `'horizontal-tb'`، تحتها. والقيمة `bookTitleMark: 'wavy'` هي ما يأخذه مستند `zh-Hant` افتراضيًا؛ وهي مكتوبة صراحةً لأن التبديل إلى `zh-Hans` كان سيطبع العناوين بين 《》. وحين تتلامس علامتان من نوع واحد، كما في 東漢班固 أو 史記項羽本紀، تتنازل كل منهما عن ثُمن em عند الطرفين المتلاقيين، فيُقرأ الخطان اسمين.

### 2 · تباعد أسطر يتّسع للعلامات

```js
// script.js, سطرًا 32–41
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
```

تقع العلامات في الفجوة بين الأسطر ولا تغيّر أبدًا المسافة بين الأسطر ([العلامات والروبي والواريتشو](/ar/docs/configuration#العلامات-والروبي-والواريتشو)). بحجم 10.5 pt على 18 pt تبلغ الفجوة 0.71 em، فتتسع لخطوط الأسماء في عمود ونقاط العمود التالي، كما في العرف الخامس في الصفحة 1. تَعُدّ الشبكة الصفحة بالحروف: 42 حرفًا نزولًا في كل سطر و17 سطرًا عرضًا، وتحرّك الهوامش لتكون مساحة النص هذه بالضبط: هامش 25.7 mm في الأعلى و21.7 mm في الأسفل ([شبكة الحروف](/ar/docs/configuration#شبكة-الحروف)).

### 3 · صفحة افتتاح واحدة تدور مع الصفحة

```js
// script.js, سطرًا 66–89
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
```

يُخرَج تصميم العنوان في اتجاه سير صفحته، فتخدم مجموعة واحدة من العناصر الاتجاهين ([الكتابة العمودية](/ar/docs/configuration#الكتابة-العمودية)). الشريط مثبّت إلى الزاوية العليا اليسرى من النزف، فيمتد عبر أعلى الصفحة الأفقية، وعلى طول الحافة اليمنى للصفحتين العموديتين، حيث تبدأ القراءة. ويتراصّ العنوان التمهيدي والعنوان واسم المؤلف في اتجاه السير أيضًا: واحد تحت الآخر في الصفحة الأفقية، وواحد يسار الآخر في العمودية. ويُقاس طول الشريط على اتجاه السير: يوقفه 181.3 mm عند أسفل النص العمودي، بعيدًا عن رقم الصفحة، ويمتد إلى ما بعد حدّ القص في الصفحة الأفقية.

### 4 · أرقام صفحات لكتاب مجلّد من اليمين

```js
// script.js, سطرًا 93–102
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
```

يجعل النص العمودي تجليد الكتاب من اليمين ([التجليد](/ar/docs/configuration#التجليد)): تقع الصفحة 1 يسار الكعب، والحافة الخارجية للصفحة الفردية هي اليسرى. يذهب رقم الصفحة الفردية إلى أسفل اليسار، والزوجية إلى أسفل اليمين، عكس الكتاب الغربي، ويطبعها `pageNumbering.format: 'trad-chinese-informal'` على صورة 一 و二 و三. ويأتي السطر التعريفي بينهما من سمة في كل عنوان، `slug="…"`؛ وهو، مع بيانات الطبع، كل ما يتغيّر بين الطبعتين الإنجليزية والإسبانية.

### 5 · حبر ثانٍ

```js
// script.js, سطرًا 17–29
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
```

تطبع الطبعة الكلاسيكية علاماتها بحبر النص. أما كتاب المختارات للطلاب فيستطيع طباعتها بلون ثانٍ، فتنفصل عن ضربات الحروف: يربطها `annotationColor` بالمدخل `mark` في لوحة الألوان، وهو القرمزي الذي تُكتب به الشروح بالحبر الأحمر، وتغيير واحد في ذلك المدخل يعيد تلوين كل خط ونقطة في الكتاب.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 077 · Proper-name and book-title marks, across and down ═════
// https://postext.dev/en/cookbook/proper-name-marks
// Code: MIT · Text: Sima Qian, Shiji 7 (PD) · Punctuation, headnote, conventions: CC BY 4.0
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
// Specimen pages for a classical reader: the opening of the Basic Annals of Xiang Yu with
// its proper-name lines and wavy title lines, set down the page and then across it.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the slug lines and the colophon ('en' | 'es')
const RECIPE = 'proper-name-marks';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the indigo of a thread-bound cover, vermilion for the marks
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// #endregion
// #region type: 五號 on 18 pt, 42 characters by 17 lines down the page
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
// #endregion

// #region answer: marks by the markup, their side by the writing mode
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
// #endregion

// #region opener: one design both ways: a band over the text across, right of it down
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
// #endregion

// #region folios: Chinese numerals at the outer foot, the slug line in the middle
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
// #endregion

// The front page sets the headnote and the conventions in the Kai face. A section's
// bodyStyle sets its list numbers bold unless its orderedLists say otherwise.
const front = { id: 'front', bodyStyle: { fontFamily: KAI, orderedLists: { fontWeight: 400 } } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // Taiwan: full-width centred punctuation (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(W), height: mm(H), dpi: 150,
    // Minimums: the grid centres its 42 × 17 characters, 天頭 25.7 mm over 地腳 21.7 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(SIDE), right: mm(SIDE), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' }, // 一, 二, 三
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: SONG, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: {
    fontFamily: HEI, fontWeight: 700, color: col('band'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener, marginBottom: pt(0) },
      // No space above: 題解 sits on the fifth line, where the other pages' text starts;
      // a :::space line sets 凡例 apart from the headnote.
      { level: 2, fontSize: pt(SIZE), lineHeight: pt(LEAD), letterSpacing: pt(2),
        marginTop: pt(0), marginBottom: pt(0) },
    ],
  },
  headingStyles: [front, across],
  // No gap after 一、: the text starts two ems in, on the grid, like a paragraph's.
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', fontFamily: KAI,
    fontWeight: 400, color: col('ink'), gap: em(0), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, // every page opens a section: the band is its head
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The Chinese is the same in both editions; the slug lines and the colophon change.
const markdown = String.raw`---
title: "史記選讀"
author: "司馬遷"
---

# 史記選讀 {style="front" kicker="版式樣張" byline="題解　凡例" slug="Specimen sheet: the headnote and the conventions"}

## 題解

:book[項羽本紀]是:book[史記]第七卷，記述:name[秦]末:name[項羽]起兵、分封諸侯，以至兵敗:name[垓下]、自刎:name[烏江]的始末。:name[司馬遷]將這位未曾稱帝的人物列入「本紀」，與帝王並列。本樣張節錄篇首，寫:name[項羽]的出身與少年志氣。:name[東漢]:name[班固]:book[漢書]:book[陳勝項籍傳]敘述同一段事，文字大多沿用:book[史記]。

:::space

## 凡例

1. 正文依維基文庫所收:book[史記]卷七錄文；標點、分段與題解為本樣張所加。
2. 人名、地名、國名、朝代名旁加專名號，直排標於字左，橫排標於字下。
3. 書名、篇名旁加浪線書名號，位置與專名號相同，如:book[史記]:book[項羽本紀]。
4. 兩個專名緊相連接時各自畫線，相接處各縮八分之一字，如:name[楚]:name[漢]、:name[東漢]:name[班固]。
5. 需要強調的字旁加著重號，直排在字右，橫排在字下，如:dots[萬人敵]。

# 項羽本紀 {kicker="樣張一　直排" byline="〔漢〕司馬遷" slug="Specimen 1, down the page: the lines stand left of the names"}

:name[項籍]者，:name[下相]人也，字:name[羽]。初起時，年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕]，為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將，封於:name[項]，故姓:name[項氏]。

:name[項籍]少時，學書不成，去學劍，又不成。:name[項梁]怒之。:name[籍]曰：「書，足以記名姓而已；劍，一人敵，不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜，略知其意，又不肯竟學。

:name[項梁]嘗有:name[櫟陽]逮，乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣]，以故事得已。:name[項梁]殺人，與:name[籍]避仇於:name[吳中]，:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪，:name[項梁]常為主辦，陰以兵法部勒賓客及子弟，以是知其能。

:name[秦始皇帝]游:name[會稽]，渡:name[浙江]，:name[梁]與:name[籍]俱觀。:name[籍]曰：「彼可取而代也！」:name[梁]掩其口曰：「毋妄言，族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘，力能扛鼎，才氣過人，雖:name[吳中]子弟，皆已憚:name[籍]矣。

# 項羽本紀 {style="across" kicker="樣張二　橫排" byline="〔漢〕司馬遷" slug="Specimen 2, across the page: the lines run under the names"}

:name[項籍]者，:name[下相]人也，字:name[羽]。初起時，年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕]，為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將，封於:name[項]，故姓:name[項氏]。

:name[項籍]少時，學書不成，去學劍，又不成。:name[項梁]怒之。:name[籍]曰：「書，足以記名姓而已；劍，一人敵，不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜，略知其意，又不肯竟學。

:name[項梁]嘗有:name[櫟陽]逮，乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣]，以故事得已。:name[項梁]殺人，與:name[籍]避仇於:name[吳中]，:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪，:name[項梁]常為主辦，陰以兵法部勒賓客及子弟，以是知其能。

:name[秦始皇帝]游:name[會稽]，渡:name[浙江]，:name[梁]與:name[籍]俱觀。:name[籍]曰：「彼可取而代也！」:name[梁]掩其口曰：「毋妄言，族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘，力能扛鼎，才氣過人，雖:name[吳中]子弟，皆已憚:name[籍]矣。

:::paragraphs{style="colophon"}
Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL Open Font License). Text: Sima Qian, Shiji, chapter 7, from zh.wikisource (public domain). Punctuation, paragraphs, headnote and conventions written for this specimen (CC BY 4.0).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// A Chinese face comes in slices: each voice loads the files of the text it sets
// (gotcha: cjk-fonts-slices). Kai sets the front page and the bylines, Hei the small heads.
const FONTS = {
  'Noto Serif TC': ['400', '700'],
  'Noto Sans TC': ['400', '700'],
  'LXGW WenKai TC': ['400'],
};
const heads = markdown.match(/^#.*$/gm).join('\n'); // titles, kickers, bylines, slug lines
const preface = markdown.slice(0, markdown.indexOf('\n# ', markdown.indexOf('# ') + 2));
const colophon = markdown.slice(markdown.lastIndexOf(':::paragraphs'));
const NUMERALS = '一二三四五六七八九十、'; // folios and list numbers, which the text may lack

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown + NUMERALS, { vertical: true });
// The bold sets the two titles, which have no punctuation, so it loads no vertical forms.
// A browser that ignores the forms' feature settings would otherwise take a twin of the
// bold files for vertical forms and set the text's brackets upright in the regular.
await loadCjkFonts({ [SONG]: ['700'] }, heads);
await loadCjkFonts({ [HEI]: ['400', '700'] }, heads + colophon, { vertical: true });
await loadCjkFonts({ [KAI]: ['400'] }, preface + heads + NUMERALS, { vertical: true });
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'Name and title marks, down and across',
  es: 'Marcas de nombre y de título, en vertical y en horizontal' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── Kit · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─
// Fontsource ships a CJK family as about a hundred files per weight, each
// declared in its stylesheet with the unicode-range it covers. The screen
// loads the files the sample touches; the PDF gets the same files for the
// characters its pages set in each face, and embeds each as a subset.
// A book bound on the right (vertical text) is shown with its spreads
// mirrored: page 1 alone on the left of the spine, then [3 | 2].

/** The files of a Fontsource face, read from its stylesheet: { url, range,
 *  ranges }, the last declared first (the order the browser tries them in). */
function cjkSlices(family, weight, style) {
  cjkSlices.cache ??= new Map();
  const id = fontsourceId(family);
  const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    cjkSlices.cache.set(css, fetch(css)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`);
        return res.text();
      })
      .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => {
        const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF';
        const ranges = range.split(',').map((part) => {
          const [lo, hi = lo] = part.trim().slice(2).split('-');
          return [parseInt(lo, 16), parseInt(hi, 16)];
        });
        return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` that holds code point `cp`, if any. */
function cjkSliceFor(slices, cp) {
  return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
}

/** Whether Fontsource serves `family` as a Chinese, Japanese or Korean
 *  family (its subsets name the script). Fails when the API does not
 *  answer: a CJK face taken for a Latin one would paint in a system face. */
async function isCjkFamily(family) {
  const meta = await fontsourceMeta(family);
  if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`);
  return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset));
}

/** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the
 *  whole FONTS object may be passed, its other families are left to
 *  loadFonts. Adds one FontFace per file of each CJK face with its
 *  unicodeRange, then loads the files `text` touches. `text` is what the
 *  faces set: the sample for the text face; a book in several voices calls
 *  it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)),
 *  so the heading and quotation faces fetch and check only their own
 *  characters. Fails when a character of `text` is in no file of a face.
 *  List every weight the pages use: a weight left to buildWithFonts gets
 *  the latin file only. With { vertical: true } it also loads each
 *  family's vertical forms (brackets, quotes, pause marks) for the canvas,
 *  which needs loadVerticalAlternates imported from postext. Resolves to
 *  the number of files loaded. */
async function loadCjkFonts(faces, text, { vertical = false } = {}) {
  kitStatus('Loading fonts…');
  let loaded = 0;
  try {
    if (vertical && typeof loadVerticalAlternates !== 'function') {
      throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext');
    }
    for (const [family, specs] of Object.entries(faces)) {
      if (!(await isCjkFamily(family))) continue;
      const twin = [];
      for (const spec of new Set(specs)) {
        const weight = parseInt(spec, 10);
        const style = spec.endsWith('i') ? 'italic' : 'normal';
        const slices = await cjkSlices(family, weight, style);
        const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0)));
        if (missing.length) {
          throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: `
            + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`);
        }
        for (const slice of slices) {
          document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`,
            { weight: String(weight), style, unicodeRange: slice.range }));
          twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range });
        }
        const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`;
        loaded += (await document.fonts.load(font, text)).length;
        if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`);
      }
      // The same files under a twin name with the `vert` feature on: the
      // canvas paints the punctuation of vertical lines with it.
      if (vertical && twin.length) await loadVerticalAlternates(family, twin);
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return loaded;
}

/** The PDF font provider for recipes with CJK faces: a family whose
 *  Fontsource subsets are Chinese, Japanese or Korean gets the files that
 *  hold the characters its pages set (`request.codePoints`); any other
 *  family 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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### اطبع العناوين بين 《》

تنضّد كتب البر الصيني خارج الكلاسيكيات العناوين بين أقواس: يبقى Markdown كما هو، وتُطبع الأقواس علاماتِ ترقيم في النص.

```diff
-  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
+  bookTitleMark: 'brackets', // 《史記》《項羽本紀》
```

### اطبع العلامات بحبر النص

اترك `annotationColor` بلا قيمة فتأخذ كل علامة لون الكتلة التي تميّزها.

```diff
-  annotationColor: col('mark'), // unset, the marks print in the colour of the text
```

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

- **اوسم المستند بـ zh-Hans أو zh-Hant، لا بـ LANG.** نسختا الوصفة هما en وes، لكن المثال الصيني صيني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، فيقسّم كلماته اللاتينية بالواصلة، ويسمّي أشكاله Figure أو Figura، ويعطي PDF لغة خاطئة. اكتب الوسم بنفسك: 'zh-Hans' (أعراف البر الرئيسي: كسر الأسطر وفق GB، وترقيم Kaiming) أو 'zh-Hant' (تايوان: ترقيم بعرض كامل متمركز)؛ و'zh-HK' لهونغ كونغ. أما 'zh' المجرّد فيُقرأ صينية مبسطة على أعراف البر الرئيسي.
- **الخطوط الصينية تُحمَّل شرائح، عبر الكتلة cjk.** يقدّم Fontsource العائلة الصينية أو اليابانية أو الكورية في نحو مئة ملف لكل وزن، يغطي كل منها نطاقًا من الحروف. لا يجلب loadFonts إلا ملف latin، فتأتي حروف الهان على الشاشة من خط النظام وتُقاس خطأً، ويسلّم fontsourceProvider ملف latin ذاك إلى PDF، فيطبعها مربعات فارغة. اذكر كتلة الأدوات cjk، واستدعِ loadCjkFonts(FONTS, markdown) بعد loadFonts (مرة لكل صوت، مع النص الذي يضعه، حين يستخدم الكتاب عدة خطوط CJK)، وأعطِ renderToPdf القيمة fontProvider: cjkPdfProvider: كلاهما يأخذ الملفات التي تحتوي حروف النص.
- **الكتاب المجلَّد على اليمين يعرض صفحاته المتقابلة بـ showBook.** في الكتاب المجلَّد على اليمين (نص عربي أو عبري أو فارسي، أو صيني عمودي، أو page.binding 'right') تبقى الصفحة 1 هي الصفحة الفردية، لكنها تقع على يسار الكعب، وتُقرأ الأزواج [3 | 2]. يعرض showPages كل كتاب مجلَّدًا على اليسار؛ أما showBook من الكتلة book (وتحمل الكتلة cjk الدالة نفسها) فيقرأ doc.binding ويعكس الأزواج. ولا يزال capture.hero يسمّي الصفحتين المتقابلتين بترتيب القراءة، [verso, recto]: [2, 3].
- **لا مائل في الصينية.** لا توفّر عائلات CJK وجهًا مائلًا، والطباعة الصينية تميّز التوكيد بنقاط بجانب الحروف، لا بالإمالة. في مستند موسوم بالصينية، يضع *…* نقاط التوكيد على الحروف الصينية التي يحتويها ويُبقي المائل للكلمات اللاتينية (cjk.emphasis: 'dots'، القيمة الافتراضية للصينية)؛ ويضعها :dots[…] في أي مكان. أما مع cjk.emphasis: 'italic'، أو في مستند موسوم بـ en أو es، فيجعل *…* لوحة Canvas تُميل الحروف القائمة، وهو مائل مصطنع لا تستخدمه الطباعة الصينية أبدًا (ويعود PDF إلى الوجه القائم): أبقِ الوسم الصيني، أو ضع التوكيد بالعريض أو بخط Kai (LXGW WenKai) عبر نمط فقرة أو نمط شارة.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **تحذير إخراج: العلامات تزاحم السطر التالي** (`cjkMarksExceedLeading`). فقرة فيها نقاط توكيد أو خطوط لأسماء الأعلام وعناوين الكتب، وفراغها بين الأسطر أقل من نصف em (خمسة أثمان إذا كانت العلامات على الجانبين): العلامات تقع في تباعد الأسطر، وارتفاعه لا يتغير من أجلها، فتلامس السطر التالي. الحل: أعطِ الفقرة نمط فقرة بتباعد أسطر أكبر، أو ارفع `bodyText.lineHeight`. ([التوثيق](https://postext.dev/ar/docs/configuration.md#العلامات-والروبي-والواريتشو))

- العنوان الذي يرسمه تصميم يطبع نصه عبر التصميم، ونص التصميم يُسقط العلامات: فلن يُظهر `# :book[項羽本紀]` أي خط متموّج في الشريط. وكذلك تطبع الترويسات والتعليقات وخلايا الجداول نصها بلا علامات.
- لا يتوقّف Postext عند `cjkMarksExceedLeading`: يسرده البناء في `doc.contentWarnings` وتُظهره لوحة الفحوص في Sandbox، لكن الصفحات تُطبع على أي حال، والعلامات تزاحم السطر التالي. حين تغيّر تباعد الأسطر اقرأ تلك القائمة؛ فبحجم 15 pt تضم كل فقرة فيها علامات في هذه الصفحات.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: The opening of the Basic Annals of Xiang Yu (項羽本紀), Records of the Grand Historian (史記), chapter 7; characters from the zh.wikisource transcription: Sima Qian (司馬遷) ([المصدر](https://zh.wikisource.org/wiki/史記/卷007)), ملكية عامة
- النص: The punctuation, the paragraphs and the marks of the passage, the headnote (題解), the conventions (凡例), the slug lines and the colophon: Ignacio Ferro, CC-BY-4.0
- الخطوط: Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 044 · طبعة محقّقة: أرقام الأسطر وحواشٍ مرتبطة بها](https://postext.dev/ar/cookbook/critical-edition-line-numbers.md): قصيدة Lycidas لملتون بنص 1645: سكربت يعدّ الأسطر ويضيف إطارًا جانبيًا بعد كل خامس، وكل حاشية تبدأ برقم سطرها منضّدًا شارةً. · المستوى 3 (متقدم) · الشعر
- [رقم 081 · تواريخ واختصارات قائمة في النص العمودي](https://postext.dev/ar/cookbook/chinese-dates-upright.md): وثيقتان تأسيسيتان من 1912 منضدتان عموديًا بتجليد على اليمين: الأعداد ذات الرقمين تقوم وحدها في خانة واحدة، ويعلّم :tcy و:upright الباقي. · المستوى 2 (متوسط) · الكتب المدرسية, الأوراق المفردة والمطبوعات العابرة
- [رقم 085 · ورقة طباعة خشبية: إطار مزدوج وخطوط وشريط أوسط](https://postext.dev/ar/cookbook/woodblock-leaf.md): شرح جو شي على المختارات منضّدًا كما طبعته الألواح الخشبية: ورقة لكل صفحتين متقابلتين، وإطار مزدوج، وخط بين الأعمدة، والشريط الأوسط على الطيّة. · المستوى 2 (متوسط) · الكتب المدرسية
