# ورقة طباعة خشبية: إطار مزدوج وخطوط وشريط أوسط

> شرح جو شي على المختارات منضّدًا كما طبعته الألواح الخشبية: ورقة لكل صفحتين متقابلتين، وإطار مزدوج، وخط بين الأعمدة، والشريط الأوسط على الطيّة.

- نسخة HTML: https://postext.dev/ar/cookbook/woodblock-leaf
- وصفة رقم 085 · الصفحة والشبكة · المستوى 2 (متوسط) · المخرجات: Canvas, PDF
- الأنواع: الكتب المدرسية
- تتطلب postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · اختُبرت مع 1.9.0, postext-pdf 1.9.0 بتاريخ 2026-09-30
- الصفحات: [1](https://postext.dev/cookbook/woodblock-leaf/en/p01.webp?v=9f006e61), [2](https://postext.dev/cookbook/woodblock-leaf/en/p02.webp?v=9f006e61), [3](https://postext.dev/cookbook/woodblock-leaf/en/p03.webp?v=9f006e61), [6](https://postext.dev/cookbook/woodblock-leaf/en/p06.webp?v=9f006e61), [7](https://postext.dev/cookbook/woodblock-leaf/en/p07.webp?v=9f006e61)
- PDF: https://postext.dev/cookbook/woodblock-leaf/en/woodblock-leaf.pdf?v=9f006e61
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=woodblock-leaf&lang=en (.postext: https://postext.dev/cookbook/woodblock-leaf/en/woodblock-leaf.postext)
- آخر تحديث: 2026-09-29
- لغات أخرى: [en](https://postext.dev/en/cookbook/woodblock-leaf.md), [es](https://postext.dev/es/cookbook/woodblock-leaf.md), [ca](https://postext.dev/ca/cookbook/woodblock-leaf.md), [zh](https://postext.dev/zh/cookbook/woodblock-leaf.md)

## باختصار

كتاب صيني كلاسيكي منضّد على هيئة الكتب القديمة المطبوعة بالألواح الخشبية. لكل صفحة إطار مزدوج وخطوط رفيعة بين الأعمدة، وتجري الحواشي بحرف صغير داخل الأسطر.

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

ثلاث أوراق من شرح جو شي على *المختارات* (論語集註)، وهي مطلع الكتاب 學而، منضّدة كما طبعتها الألواح الخشبية، بعد صفحة عنوان. كل صفحة نصف ورقة، بمقاس 121 × 213 مم، وكل صفحتين متقابلتين ورقة كاملة تُقرأ من اليمين: في كل نصف تسعة أعمدة من 17 حرفًا بخط Noto Serif TC بحجم 20 نقطة، وإطار مزدوج، وخط رفيع بين كل عمودين، وعلى الطيّة الشريط الأوسط (版心) وفيه العنوان، وذيل سمكة أسود، والجزء (juan)، ورقم الورقة. وتُطوى حواشي جو شي في صفّين من حروف بحجم 10 نقاط داخل العمود، بعد الكلمات التي تشرحها. والهامش العلوي ضعف الهامش السفلي.

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

- كيف أنضّد ورقة طباعة خشبية بإطارها المزدوج وخطوط أعمدتها وشريطها الأوسط عبر الصفحتين المتقابلتين؟
- كيف أضبط منطقة النص بالحروف، بعدد محدد من الحروف في السطر وعدد محدد من الأسطر في الصفحة؟
- كيف أنضد كتابًا صينيًّا عموديًّا، مجلَّدًا من اليمين؟

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

```js
// script.js, سطرًا 46–64
// A block printed both halves of a leaf (葉) at once, the centre strip between them. Each
// page here is a half-leaf. Page 1, the title, is a recto: in a book bound on the right it
// stands alone left of the spine, and every spread after it is one leaf, read from the
// right. The grid sets the block's 9 columns of 17 characters; the text has no
// punctuation, so every column ends on its 17th character.
const page = {
  sizePreset: 'custom', width: pt(W), height: pt(H), dpi: 150,
  backgroundColor: col('paper'),
  // left is the inner margin, the half of the centre strip on this side of the fold
  margins: { top: pt(TOP), bottom: pt(BOTTOM), left: pt(HALF), right: pt(OUTER), mirror: true },
};
const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // bound on the right
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// Postext numbers pages, not leaves. Each leaf opens with a hidden level-1 heading, which
// prints nothing and starts a right-hand page; the centre strip prints their count.
const leaf = {
  level: 1, hidden: true, numberingTemplate: '{1:一}', // 一, 二, 三
  breakBefore: { enabled: true, parity: 'even' }, // gotcha: headings-drop-h1-break
};
```

## المكونات

**تعلّم**

- [النصوص والخطوط والمربعات في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها): عناصر الرسم المشتركة بين الرؤوس والتذييلات وصفحات الافتتاح وصفحات الأجزاء: نص بكبسولة اختيارية، وخطوط أفقية وعمودية، ومربعات مملوءة أو مؤطَّرة تُرسم بترتيب المصفوفة.
- [شبكة المحارف](https://postext.dev/ar/docs/configuration.md#شبكة-الحروف): منطقة النص محدّدة على الطريقة الصينية، بعدد المحارف في السطر وعدد الأسطر في الصفحة (cjk.grid): عرض السطر عدد صحيح من em، وتتسع الهوامش لتوسيطه، ويمكن رسم الشبكة على الشاشة.
- [النص العمودي](https://postext.dev/ar/docs/configuration.md#الكتابة-العمودية): صينية ويابانية تُصفّ من الأعلى إلى الأسفل في أسطر تُقرأ بدءًا من اليمين (layout.writingMode 'vertical-rl')، والأعمدة طبقات، والأشكال والجداول قائمة، وعلامات الترقيم بأشكالها العمودية.

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

- [Warichu: تعليقات بسطرين داخل السطر](https://postext.dev/ar/docs/document-format.md#العلامات-الصينية-والقراءات-ruby-والتعليقات-السطرية-warichu)
- [كتب مجلّدة من اليمين](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/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/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/document-format.md#الموارد)

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

- [`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#تقسيم-الكلمات-بالواصلة), [`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), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية), [`renderToPdf`](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-pdf)

**الخطوط**

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

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

### 1 · نصف ورقة لكل صفحة، وورقة لكل صفحتين متقابلتين

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. كان اللوح يطبع ورقة كاملة (葉) دفعة واحدة، نصفاها على جانبي الشريط الأوسط؛ وكان الطابع يطوي الورقة على امتداد الشريط، والوجه المطبوع إلى الخارج، ويخيط الحواف المفتوحة، فيصير الشريط الحافة الأمامية للكتاب. وهذه الصفحات مبسوطة كما خرجت الورقة من اللوح. كل صفحة نصف ورقة. وفي [الكتاب المجلّد من اليمين](/ar/docs/configuration#التجليد) تقف الصفحة 1 وحدها إلى يسار الكعب، وكل صفحتين متقابلتين بعدها تجمعان صفحة زوجية على اليمين والصفحة الفردية التي تليها، فإذا كان العنوان على الصفحة 1 حملت كل صفحتين متقابلتين ورقة واحدة. وتحدد [شبكة الحروف](/ar/docs/configuration#شبكة-الحروف) نظام الأعمدة في اللوح (行款)، تسعة أعمدة من 17 حرفًا، والهوامش التي تتركها دقيقة: الداخلي منها نصف الشريط.

يرقّم Postext الصفحات لا الأوراق. فكل ورقة تبدأ بعنوان من المستوى 1 [مخفي](/ar/docs/configuration#تجاوزات-كل-مستوى): لا يطبع شيئًا ولا يأخذ مكانًا، لكن `breakBefore` الخاص به مع `parity: 'even'` يبدأ صفحة يمنى، ورقمه، 一 و二 و三 بفضل `numberingTemplate: '{1:一}'`، هو `{chapterNumber}` الذي يقرؤه الشريط.

### 2 · اكسر النص حيث تنتهي كل ورقة

كان الناسخ الذي يكتب النص لنقّاش اللوح يطابقه على اللوح، ورقةً ورقة. ونص Markdown يفعل مثله: يوضع عنوان الورقة حيث ينتهي العمود التاسع من النصف الأيسر للورقة السابقة، ولو كان ذلك داخل جملة أو حاشية. والحاشية المقطوعة هناك تُغلق ثم تُفتح من جديد، ولأن كل عمود يطوي نصيبه من الحاشية في صفّين، تخرج الصفوف كما كانت:

```markdown
子曰巧言令色鮮矣仁:warichu[巧好令善也好其言善其色致飾於]

# 學而

:warichu[外務以悦人則人欲肆而本心之德亡矣…]
```

ليس في اللوح علامات ترقيم، فلا تنقل أي قاعدة من قواعد كسر الأسطر حرفًا من مكانه، وينتهي كل عمود عند حرفه السابع عشر: تُعدّ فواصل الأوراق مرة واحدة وتبقى في مكانها. وحواشي جو شي مقاطع [`:warichu[…]`](/ar/docs/document-format#العلامات-الصينية-والقراءات-ruby-والتعليقات-السطرية-warichu) بنصف الحجم الافتراضي، 34 حرفًا صغيرًا في الصف، وبين القراءة والشرح ○ كما في اللوح.

### 3 · ارسم اللوح من أرقام الشبكة

```js
// script.js, سطرًا 68–113
// Header elements are placed on the sheet, pt from the page's top left corner. Both halves
// read right to left: a right-hand page (even) from the outer frame to the fold, a
// left-hand page (odd) from the fold to the outer frame.
const at = (x, y) => ({ anchor: { to: 'page', edge: 'top-left' }, offset: { x: pt(x), y: pt(y) } });
// A column of characters stands in the middle of its pitch, so the rules between columns
// fall on whole pitches. The k-th, counted from the right edge of the type area:
const line = (parity, k) =>
  (parity === 'even' ? HALF + LINES * PITCH : W - HALF) - k * PITCH;
const [Y0, Y1] = [TOP - GAP, TOP + CHARS * BODY + GAP]; // the inner frame's top and foot
// A rule is drawn from its x, a box's border inside the box: each is set so that the line
// is centred where it belongs.
const rule = (id, x, parity) => ({ kind: 'rule', id, parity, direction: 'vertical',
  thickness: pt(0.6), color: col('rule'),
  placement: { ...at(x - 0.3, Y0), size: { height: pt(Y1 - Y0) } } });
const frame = (id, [x0, x1], parity) => [[RULES, 1.8], [0, 0.6]].map(([out, t], i) => ({
  kind: 'box', id: `${id}-${i}`, parity, style: { borderColor: col('rule'), borderWidth: pt(t) },
  placement: { ...at(x0 - out - t / 2, Y0 - out - t / 2),
    size: { width: pt(x1 - x0 + 2 * out + t), height: pt(Y1 - Y0 + 2 * out + t) } } }));
// The middle of the strip, where its text stands: the fold, halfway between its two rules.
// Both pages draw the text whole, and each shows its half.
const mid = (parity) => (parity === 'even' ? 0 : W);
const strip = (id, content, parity, y) => ({ kind: 'text', id: `${id}-${parity}`, parity,
  content, writingMode: 'vertical-rl', fontFamily: SONG, fontSize: pt(11), color: col('ink'),
  align: 'left', overflow: 'clip',
  placement: { ...at(mid(parity) - HALF, TOP + y),
    size: { width: pt(2 * HALF), height: pt(CHARS * BODY - y) } } });
// The fish tail (魚尾): a black band with a notch, from one side of the strip to the other.
const FISH = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 18">
  <path d="M0 0H32V13L16 6L0 13Z" fill="${palette.rule}"/></svg>`;
const resources = [{ id: 'fish', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'fish.svg', width: 32, height: 18 } }];
const fish = (parity) => ({ kind: 'image', id: `fish-${parity}`, parity, resourceId: 'fish',
  decorative: true, placement: { ...at(mid(parity) - HALF + 0.3, TOP + 3.4 * BODY),
    size: { width: pt(2 * HALF - 0.6), height: 'auto' } } });
const furniture = (parity) => [
  // The frame runs 40 pt past the fold, where the other half of the leaf takes it on.
  ...frame(`frame-${parity}`, parity === 'even' ? [-40, line(parity, 0)]
    : [line(parity, LINES), W + 40], parity),
  // Nine rules: eight between the columns (界行) and the side of the strip.
  ...Array.from({ length: LINES }, (_, i) =>
    rule(`rule-${i}-${parity}`, line(parity, parity === 'even' ? i + 1 : i), parity)),
  strip('book', '{title}', parity, 0.7 * BODY), // 書名, above the fish tail
  fish(parity),
  strip('juan', '卷一', parity, 5.2 * BODY), // 卷次, under it
  strip('leaf', '{chapterNumber}', parity, 11.5 * BODY), // 葉碼, the leaf's number
];
```

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

### 4 · أعطِ صفحة العنوان نمطًا خاصًّا بها

```js
// script.js, سطرًا 117–143
// A heading style of its own: hidden and unnumbered, so the leaves still count from 一, and
// its header replaces the leaves' furniture on its page. Its attributes hold the byline,
// this edition's note in red and the colophon, the one line that changes with the language.
const CELLS = [0, 2, LINES - 2, LINES]; // the byline, title and note, in pitches from the right
const cell = (i) => [CELLS[i], CELLS[i + 1]];
const column = (id, content, [k0, k1], style) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', color: col('ink'), overflow: 'clip', verticalAlign: 'middle',
  placement: { ...at(line('odd', k1), TOP),
    size: { width: pt((k1 - k0) * PITCH), height: pt(CHARS * BODY) } }, ...style });
const titlePage = {
  id: 'title', numbered: false, hidden: true, breakBefore: { enabled: true, parity: 'any' },
  footer: { elements: [] },
  header: { elements: [
    ...frame('title-frame', [line('odd', LINES), line('odd', 0)]),
    rule('title-rule-1', line('odd', CELLS[1])), rule('title-rule-2', line('odd', CELLS[2])),
    column('byline', '{attr.byline}', cell(0), { fontFamily: SONG, fontSize: pt(14),
      align: 'left' }),
    column('book', '{title}', cell(1), { fontFamily: KAI, fontSize: pt(60),
      letterSpacing: pt(20), align: 'center' }),
    column('note', '{attr.note}', cell(2), { fontFamily: KAI, fontSize: pt(11),
      lineHeight: pt(0.6 * PITCH), color: col('vermilion'), align: 'right' }), // 3 in 2 pitches
    { kind: 'text', id: 'colophon', content: '{attr.colophon}', fontFamily: SONG,
      fontSize: pt(6.5), lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at(line('odd', LINES) - RULES - 0.9, Y1 + RULES + 16),
        size: { width: pt(LINES * PITCH) } } },
  ] },
};
```

صفحة العنوان (內封) عنوان من المستوى 1 له [نمط عنوان](/ar/docs/configuration#أنماط-العناوين) مخفي وغير مرقّم، فتبقى الأوراق معدودة من 一. يتحكم النمط في صفحات قسمه، ويحلّ `header` الخاص به محلّ ترويسة المستند هناك: فتأخذ صفحة العنوان إطارًا مغلقًا مقسومًا إلى ثلاثة أعمدة بدل عُدّة نصف الورقة. ويأتي اسم المؤلف، وملاحظة هذه الطبعة بخط Kai أحمر، وبيانات الطبع من [سمات](/ar/docs/document-format#سمات-العناوين) العنوان؛ وبيانات الطبع هي السطر الوحيد الذي يتغير بتغير لغة الطبعة. و`\n` في السمة تبدأ عمودًا جديدًا.

### 5 · حمّل كل وجه بالحروف التي ينضّدها

```js
// script.js, سطرًا 200–205
// Song sets the leaves, the strip and its leaf numbers; Kai only the title page's title
// and note (gotcha: cjk-fonts-slices). No punctuation: no vertical forms to load.
const note = markdown.match(/note="([^"]*)"/)?.[1].replaceAll('\\n', '') ?? '';
await loadFonts(FONTS, markdown); // their Latin files: the colophon
await loadCjkFonts({ [SONG]: FONTS[SONG] }, `${markdown}論語集註卷一二三四五六七八九十`);
await loadCjkFonts({ [KAI]: FONTS[KAI] }, `論語集註${note}`);
```

يقدّم Fontsource كل وجه صيني في نحو مئة ملف لكل وزن. ينضّد Song الأوراق والشريط وأرقام الأوراق، فيأخذ استدعاؤه النموذج وتلك الحروف؛ أما Kai فلا ينضّد إلا العنوان والملاحظة. وليس في اللوح علامات ترقيم، فلا يحتاج أي من الوجهين إلى الأشكال العمودية التي يحمّلها `loadCjkFonts(…, { vertical: true })`.

### 6 · حبر واحد على ورق دافئ

```js
// script.js, سطرًا 19–30
const palette = {
  ink: '#1f1b17', // the text, as the block printed it
  rule: '#1f1b17', // the frame, the column rules and the fish tail: the same block, same ink
  vermilion: '#a3301f', // 朱: this edition's note, on the title page; 5.9 : 1 on the paper
  muted: '#6d6358', // the colophon
  paper: '#f2ead8',
};
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: 'defaults', value: { hex: palette.ink, model: 'hex' } },
];
```

يُطبع النص وخطوط اللوح بأسود دافئ واحد، بتباين 14 : 1 مع الورق. ومع ذلك هما مدخلان في اللوحة، `ink` و`rule`، فيمكن تغيير لون الخطوط دون النص. ويُحفظ الأحمر لما تضيفه هذه الطبعة.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 085 · A woodblock leaf: double frame, rules and centre strip ═══
// https://postext.dev/en/cookbook/woodblock-leaf
// Code: MIT · Text: 論語集註 卷一, 四庫全書 copy, zh.wikisource (CC BY-SA 4.0) · Pictures: none
// Fonts: Noto Serif TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
// A title page and three leaves of Zhu Xi's commentary on the Analects, set as a woodblock
// printed them: each spread is one leaf, its two halves either side of the centre strip
// (版心), the text in a double frame with a rule between every two columns, the commentary
// folded into two small rows inside the column.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the colophon; the leaves are Chinese in both
const RECIPE = 'woodblock-leaf';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink on a warm paper, and a red for the editor's note
const palette = {
  ink: '#1f1b17', // the text, as the block printed it
  rule: '#1f1b17', // the frame, the column rules and the fish tail: the same block, same ink
  vermilion: '#a3301f', // 朱: this edition's note, on the title page; 5.9 : 1 on the paper
  muted: '#6d6358', // the colophon
  paper: '#f2ead8',
};
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: 'defaults', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const [SONG, KAI] = ['Noto Serif TC', 'LXGW WenKai TC']; // 宋: the block's face; 楷: the title
// The leaf in points: 半葉九行，行十七字, a 20 pt character on a column pitch of 29 pt.
const [BODY, PITCH, CHARS, LINES] = [20, 29, 17, 9];
const HALF = 17; // half the centre strip, from the fold to the column rule beside it
const GAP = 4.5; // the text to the inner frame line, as a character to a column rule
const RULES = 4.5; // the thin inner frame line to the heavy outer one (文武線)
const [HEAD, FOOT, SIDE] = [164, 82, 60]; // paper outside the frame: 天頭 twice 地腳
const TOP = HEAD + RULES + GAP; // the page's margins
const BOTTOM = FOOT + RULES + GAP;
const OUTER = SIDE + RULES;
const W = HALF + LINES * PITCH + OUTER; // a half-leaf, 121 × 213 mm
const H = TOP + CHARS * BODY + BOTTOM;

// #region answer: a half-leaf to a page, so that every spread is one whole leaf
// A block printed both halves of a leaf (葉) at once, the centre strip between them. Each
// page here is a half-leaf. Page 1, the title, is a recto: in a book bound on the right it
// stands alone left of the spine, and every spread after it is one leaf, read from the
// right. The grid sets the block's 9 columns of 17 characters; the text has no
// punctuation, so every column ends on its 17th character.
const page = {
  sizePreset: 'custom', width: pt(W), height: pt(H), dpi: 150,
  backgroundColor: col('paper'),
  // left is the inner margin, the half of the centre strip on this side of the fold
  margins: { top: pt(TOP), bottom: pt(BOTTOM), left: pt(HALF), right: pt(OUTER), mirror: true },
};
const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // bound on the right
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// Postext numbers pages, not leaves. Each leaf opens with a hidden level-1 heading, which
// prints nothing and starts a right-hand page; the centre strip prints their count.
const leaf = {
  level: 1, hidden: true, numberingTemplate: '{1:一}', // 一, 二, 三
  breakBefore: { enabled: true, parity: 'even' }, // gotcha: headings-drop-h1-break
};
// #endregion

// #region frame: the double frame, a rule between every two columns, the centre strip
// Header elements are placed on the sheet, pt from the page's top left corner. Both halves
// read right to left: a right-hand page (even) from the outer frame to the fold, a
// left-hand page (odd) from the fold to the outer frame.
const at = (x, y) => ({ anchor: { to: 'page', edge: 'top-left' }, offset: { x: pt(x), y: pt(y) } });
// A column of characters stands in the middle of its pitch, so the rules between columns
// fall on whole pitches. The k-th, counted from the right edge of the type area:
const line = (parity, k) =>
  (parity === 'even' ? HALF + LINES * PITCH : W - HALF) - k * PITCH;
const [Y0, Y1] = [TOP - GAP, TOP + CHARS * BODY + GAP]; // the inner frame's top and foot
// A rule is drawn from its x, a box's border inside the box: each is set so that the line
// is centred where it belongs.
const rule = (id, x, parity) => ({ kind: 'rule', id, parity, direction: 'vertical',
  thickness: pt(0.6), color: col('rule'),
  placement: { ...at(x - 0.3, Y0), size: { height: pt(Y1 - Y0) } } });
const frame = (id, [x0, x1], parity) => [[RULES, 1.8], [0, 0.6]].map(([out, t], i) => ({
  kind: 'box', id: `${id}-${i}`, parity, style: { borderColor: col('rule'), borderWidth: pt(t) },
  placement: { ...at(x0 - out - t / 2, Y0 - out - t / 2),
    size: { width: pt(x1 - x0 + 2 * out + t), height: pt(Y1 - Y0 + 2 * out + t) } } }));
// The middle of the strip, where its text stands: the fold, halfway between its two rules.
// Both pages draw the text whole, and each shows its half.
const mid = (parity) => (parity === 'even' ? 0 : W);
const strip = (id, content, parity, y) => ({ kind: 'text', id: `${id}-${parity}`, parity,
  content, writingMode: 'vertical-rl', fontFamily: SONG, fontSize: pt(11), color: col('ink'),
  align: 'left', overflow: 'clip',
  placement: { ...at(mid(parity) - HALF, TOP + y),
    size: { width: pt(2 * HALF), height: pt(CHARS * BODY - y) } } });
// The fish tail (魚尾): a black band with a notch, from one side of the strip to the other.
const FISH = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 18">
  <path d="M0 0H32V13L16 6L0 13Z" fill="${palette.rule}"/></svg>`;
const resources = [{ id: 'fish', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'fish.svg', width: 32, height: 18 } }];
const fish = (parity) => ({ kind: 'image', id: `fish-${parity}`, parity, resourceId: 'fish',
  decorative: true, placement: { ...at(mid(parity) - HALF + 0.3, TOP + 3.4 * BODY),
    size: { width: pt(2 * HALF - 0.6), height: 'auto' } } });
const furniture = (parity) => [
  // The frame runs 40 pt past the fold, where the other half of the leaf takes it on.
  ...frame(`frame-${parity}`, parity === 'even' ? [-40, line(parity, 0)]
    : [line(parity, LINES), W + 40], parity),
  // Nine rules: eight between the columns (界行) and the side of the strip.
  ...Array.from({ length: LINES }, (_, i) =>
    rule(`rule-${i}-${parity}`, line(parity, parity === 'even' ? i + 1 : i), parity)),
  strip('book', '{title}', parity, 0.7 * BODY), // 書名, above the fish tail
  fish(parity),
  strip('juan', '卷一', parity, 5.2 * BODY), // 卷次, under it
  strip('leaf', '{chapterNumber}', parity, 11.5 * BODY), // 葉碼, the leaf's number
];
// #endregion

// #region title: the title page (內封), a half-leaf in a closed frame, three columns
// A heading style of its own: hidden and unnumbered, so the leaves still count from 一, and
// its header replaces the leaves' furniture on its page. Its attributes hold the byline,
// this edition's note in red and the colophon, the one line that changes with the language.
const CELLS = [0, 2, LINES - 2, LINES]; // the byline, title and note, in pitches from the right
const cell = (i) => [CELLS[i], CELLS[i + 1]];
const column = (id, content, [k0, k1], style) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', color: col('ink'), overflow: 'clip', verticalAlign: 'middle',
  placement: { ...at(line('odd', k1), TOP),
    size: { width: pt((k1 - k0) * PITCH), height: pt(CHARS * BODY) } }, ...style });
const titlePage = {
  id: 'title', numbered: false, hidden: true, breakBefore: { enabled: true, parity: 'any' },
  footer: { elements: [] },
  header: { elements: [
    ...frame('title-frame', [line('odd', LINES), line('odd', 0)]),
    rule('title-rule-1', line('odd', CELLS[1])), rule('title-rule-2', line('odd', CELLS[2])),
    column('byline', '{attr.byline}', cell(0), { fontFamily: SONG, fontSize: pt(14),
      align: 'left' }),
    column('book', '{title}', cell(1), { fontFamily: KAI, fontSize: pt(60),
      letterSpacing: pt(20), align: 'center' }),
    column('note', '{attr.note}', cell(2), { fontFamily: KAI, fontSize: pt(11),
      lineHeight: pt(0.6 * PITCH), color: col('vermilion'), align: 'right' }), // 3 in 2 pitches
    { kind: 'text', id: 'colophon', content: '{attr.colophon}', fontFamily: SONG,
      fontSize: pt(6.5), lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at(line('odd', LINES) - RULES - 0.9, Y1 + RULES + 16),
        size: { width: pt(LINES * PITCH) } } },
  ] },
};
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette, page, layout, cjk,
  header: { elements: [...furniture('even'), ...furniture('odd')] },
  footer: { elements: [] },
  bodyText: {
    fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(PITCH), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: pt(0), indentAfterHeading: false, // 頂格
  },
  headings: { fontFamily: SONG, fontWeight: 400, color: col('ink'), levels: [leaf] },
  headingStyles: [titlePage],
  // The byline's seven characters (宋　朱子　集註) end at the foot of the column.
  paragraphStyles: [{ id: 'byline', indent: em(CHARS - 7), firstLineIndent: pt(0),
    textAlign: 'left' }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "論語集註"
author: "朱熹"
---

# 論語集註 {style="title" byline="宋　朱子　集註" note="據四庫全書本錄文\n仿刻本版式重排\n半葉九行行十七字" colophon="Set in Noto Serif TC and LXGW WenKai TC (SIL OFL) · Text: 論語集註, 四庫全書 copy, Wikisource transcription (CC BY-SA 4.0)"}

# 學而

論語卷一

:::paragraphs{style="byline"}
宋　朱子　集註
:::

學而第一:warichu[此為書之首篇故所記多務本之意乃入道之門積德之基學者之先務也凡十六章]

子曰學而時習之不亦說乎:warichu[說悦同○學之為言效也人性皆善而覺有先後後覺者必效先覺之所為乃可以明善而復其初也習鳥數飛也學之不已如鳥數飛也說喜意也既學而又時時習之則所學者熟而中心喜說其進自不能已矣程子曰習重習也時復思繹浹洽於中則說也又曰學者將以行之也時習之則所學者在我故說謝氏曰時習者無時而不習坐如尸坐時習也立如齊立時習也]有朋自遠方來不亦樂乎:warichu[樂音洛○朋同類也自遠方來則近者可知程子曰以善及人而信從者衆故可樂又曰說在心樂主發散在外]人不知而不慍不亦君子乎:warichu[慍紆問反○慍含怒意君子成德之名尹氏曰學在己知不知在人何慍之有程子曰雖樂於及人不見是而無悶乃所謂君子愚謂及人而樂者順而易不知而不慍者逆而難故惟成德者能之然德之所以成亦曰學之正習之熟說之深而不已焉耳○程子曰樂由說而後得非樂不足以語君子]○有子曰其為人也孝弟而好犯上者鮮矣不好犯上而好作亂者未之有也:warichu[弟好皆去聲鮮上聲下同○有子孔子弟子名若善事父母為孝善事兄長為弟犯上謂干犯在上之人鮮少也作亂則為悖逆争鬭之事矣此言人能孝弟則其心和順少好犯上必不好作亂也]君子務本本立而道生孝弟也者其為仁之本與:warichu[與平聲○務專力也本猶根也仁者愛之理心之德也為仁猶曰行仁與者疑辭謙退不敢質言也言君子凡事專用力於根本根本既立則其道自生若上文所謂孝弟乃是為仁之本學者務此則仁道自此而生也○程子曰孝弟順德也故不好犯上豈復有逆理亂常之事德有本本立則其道充大孝弟行於家而後仁愛及於物所謂親親而仁民也故為仁以孝弟為本論性則以仁為孝弟之本或問孝弟為仁之本此是由孝弟可以至仁否曰非也謂行仁自孝弟始孝弟是仁之一事謂之行仁之本則可謂是仁之本則不可蓋仁是性也孝弟是用也性中只有箇仁義禮智四者而已曷嘗有孝弟來然仁主於愛愛莫大於愛親故曰孝弟也者其為仁之本與]○子曰巧言令色鮮矣仁:warichu[巧好令善也好其言善其色致飾於]

# 學而

:warichu[外務以悦人則人欲肆而本心之德亡矣聖人詞不迫切專言鮮則絕無可知學者所當深戒也○程子曰知巧言令色之非仁則知仁矣]○曾子曰吾日三省吾身為人謀而不忠乎與朋友交而不信乎傳不習乎:warichu[省悉井反為去聲傳平聲○曾子孔子弟子名參字子輿盡己之謂忠以實之謂信傳謂受之於師習謂熟之於己曾子以此三者日省其身有則改之無則加勉其自治誠切如此可謂得為學之本矣而三者之序則又以忠信為傳習之本也○尹氏曰曾子守約故動必求諸身謝氏曰諸子之學皆出於聖人其後愈遠而愈失其真獨曾子之學專用心於內故傳之無弊觀於子思孟子可見矣惜乎其嘉言善行不盡傳於世也其幸存而未泯者學者其可不盡心乎]○子曰道千乘之國敬事而信節用而愛人使民以時:warichu[道乘皆去聲○道治也千乘諸侯之國其地可出兵車千乘者也敬者主一無適之謂敬事而信者敬其事而信於民也時謂農隙之時言治國之要在此五者亦務本之意也○程子曰此言至淺然當時諸侯果能此亦足以治其國矣聖人言雖至近上下皆通此三言者若推其極堯舜之治亦不過此若常人之言近則淺近而已矣楊氏曰上不敬則下慢不信則下疑下慢而疑事不立矣敬事而信以身先之也易曰節以制度不傷財不害民蓋侈用則傷財傷財必至於害民故愛民必先於節用然使之不以其時則力本者不獲自盡雖有愛人之心而人不被其澤矣然此特論其所存而已未及為政也茍無是心則雖有政不行焉胡氏曰凡此數者又皆以敬為主愚謂五者反復相因各有次第讀者宜細推之]○子曰弟子入則孝出則弟謹而信汎愛衆而親仁行有餘力則以學文:warichu[弟子之弟上聲則弟之弟去聲○謹者行之有常也信者言之有實也汎廣也衆謂衆人親近也仁謂仁者餘力猶言暇日以用也文謂詩書六藝之文○程子曰為弟子之職力有餘則學文不脩其職而先文非為己之學也尹氏曰德行本也文藝末也窮其本末知所先後可以入德矣洪氏曰未有餘力而學文則文滅其質有餘力而不學文則質勝而野愚謂力行而不學文則無以考聖賢之成法識事理之當然而所行或出於私意非但失之於野而已]○子夏曰賢賢易色事父母能竭其力事君能致其身與朋友交言而有信雖曰未學吾必謂之學矣:warichu[子夏孔子弟子姓卜名商賢人之賢而易其好色之心好善有誠也致猶委也委致其身謂不有其身也四者皆人倫之大者而行之必盡其誠學求如是而已故子夏言有能如]

# 學而

:warichu[是之人茍非生質之美必其務學之至雖或以為未嘗為學我必謂之已學也○游氏曰三代之學皆所以明人倫也能是四者則於人倫厚矣學之為道何以加此子夏以文學名而其言如此則古人之所謂學者可知矣故學而一篇大抵皆在於務本吳氏曰子夏之言其意善矣然詞氣之閒抑揚太過其流之弊將或至於廢學必若上章夫子之言然後為無弊也]○子曰君子不重則不威學則不固:warichu[重厚重威威嚴固堅固也輕乎外者必不能堅乎內故不厚重則無威嚴而所學亦不堅固也]主忠信:warichu[人不忠信則事皆無實為惡則易為善則難故學者必以是為主焉程子曰人道惟在忠信不誠則無物且出入無時莫知其鄉者人心也若無忠信豈復有物乎]無友不如己者:warichu[無毋通禁止辭也友所以輔仁不如己則無益而有損]過則勿憚改:warichu[勿亦禁止之辭憚畏難也自治不勇則惡日長故有過則當速改不可畏難而苟安也程子曰學問之道無他也知其不善則速改以從善而已○程子曰君子自脩之道當如是也游氏曰君子之道以威重為質而學以成之學之道必以忠信為主而以勝己者輔之然或吝於改過則終無以入德而賢者亦未必樂告以善道故以過勿憚改終焉]○曾子曰慎終追遠民德歸厚矣:warichu[慎終者喪盡其禮追遠者祭盡其誠民德歸厚謂下民化之其德亦歸於厚蓋終者人之所易忽也而能謹之遠者人之所易忘也而能追之厚之道也故以此自為則己之德厚下民化之則其德亦歸於厚也]○子禽問於子貢曰夫子至於是邦也必聞其政求之與抑與之與:warichu[之與之與平聲下同○子禽姓陳名亢子貢姓端木名賜皆孔子弟子或曰亢子貢弟子未知孰是抑反語辭]子貢曰夫子溫良恭儉讓以得之夫子之求之也其諸異乎人之求之與:warichu[溫和厚也良易直也恭莊敬也儉節制也讓謙遜也五者夫子之盛德光輝接於人者也其諸語辭也人他人也言夫子未嘗求之但其德容如是故時君敬信自以其政就而問之耳非若他人必求之而後得也聖人過化存神之妙未易窺測然即此而觀則其德盛禮恭而不願乎外亦可見矣學者所當潛心而勉學也○謝氏曰學者觀於聖人威儀之閒亦可以進德矣若子貢亦可謂善觀聖人矣亦可謂善言德行矣今去聖人千五百年以此五者想見其形容尚能使人興起而况於親炙之者乎張敬夫曰夫子至是邦必聞其政而未有能委國而授之以政者蓋見聖人之儀刑而樂告之者秉彞好德之良心也而私欲害之是以終不能用耳]
`; // content.<lang>.md: the same leaves, another colophon

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Noto Serif TC': ['400'], // 宋: the text, the notes, the centre strip, the colophon
  'LXGW WenKai TC': ['400'], // 楷: the title page's title and this edition's note
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each face loads the files of the characters it sets
// Song sets the leaves, the strip and its leaf numbers; Kai only the title page's title
// and note (gotcha: cjk-fonts-slices). No punctuation: no vertical forms to load.
const note = markdown.match(/note="([^"]*)"/)?.[1].replaceAll('\\n', '') ?? '';
await loadFonts(FONTS, markdown); // their Latin files: the colophon
await loadCjkFonts({ [SONG]: FONTS[SONG] }, `${markdown}論語集註卷一二三四五六七八九十`);
await loadCjkFonts({ [KAI]: FONTS[KAI] }, `論語集註${note}`);
// #endregion
await loadSvg('fish.svg', FISH);
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
showBook(doc, { title: t({ en: 'A woodblock leaf: double frame, rules and centre strip',
  es: 'Una hoja xilográfica: marco doble, filetes y franja central' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${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 · 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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### خطّط الأعمدة بالأحمر

النسخ التي أُعدّت لـ四庫全書 كُتبت على ورق مخطّط بالأحمر (朱絲欄). غيّر لون الخطوط؛ ويتبعه ذيل السمكة.

```diff
-  rule: '#1f1b17', // the frame, the column rules and the fish tail: the same block, same ink
+  rule: '#b3412e', // 朱絲欄: red rules under black text
```

### انقش لوحًا مختلفًا

جاءت الألواح بأعداد كثيرة، و半葉十行，行二十字 من أشيعها. والإطارات والشريط وخانات صفحة العنوان الثلاث وإزاحة اسم المؤلف كلها محسوبة من هذه الأرقام، فتتبعها؛ أما فواصل الأوراق في نص Markdown فيجب عدّها من جديد.

```diff
-const [BODY, PITCH, CHARS, LINES] = [20, 29, 17, 9];
+const [BODY, PITCH, CHARS, LINES] = [17, 24.5, 20, 10];
```

### قارِنها بصفحة أوروبية على شبكة

تبني وصفة [صفحة كتاب على شبكة خطوط الأساس](https://postext.dev/ar/cookbook/baseline-grid-book-page.md) منطقة طباعتها من عدد الأسطر، كما تبني هذه الورقة منطقتها من الأعمدة والحروف؛ وترسم [الشهادة](https://postext.dev/ar/cookbook/certificate-single-page.md) إطارها في ترويسة نمط عنوان، كما تفعل صفحة العنوان هنا.

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

- **اوسم المستند بـ 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].
- **النسخ التقليدية المنقولة تحمل حروفًا مبسطة شاردة.** النصوص المنسوخة من Wikisource أو من طبعة ويب أخرى لكتاب تقليدي تحمل أحيانًا شكلًا مبسطًا (颙 بدل 顒 في 三國演義، الفصل 1)، وقد لا يملكه خط TC مثل Noto Serif TC. يوقف loadCjkFonts مثال CodePen ويذكر الحرف في رسالته؛ وفي الصفحة كان سيُطبع بخط بديل. أعِد الشكل التقليدي في المثال واذكر ذلك في الحقوق.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.

- فواصل الأوراق تعيش في نص Markdown. غيّر النص أو الحجم أو نظام الأعمدة (行款)، فإن الورقة التي تطول تدفع العنوان التالي صفحةً إلى الأمام، فتكفّ الصفحات المتقابلة عن مطابقة الأوراق؛ والورقة التي تقصر تترك أعمدة فارغة قبل الطيّة. افحص العمود الأخير من كل صفحة يسرى.
- يكتب نسخ 四庫全書 الحرف 巳 مكان 已 و己 على السواء؛ وهذا النموذج يقرأ كلًّا منها من جملته. ومع ذلك تحتاج الصفحة إلى مقابلة بالصور الممسوحة، والأثر ظاهر: ملاحظة 張敬夫 تُقرأ 必開其政 حيث في *المختارات* التي يرددها 必聞其政، وحرفان يأتيان صورتين للرسم. والنموذج ينضّد 聞 و傳 و邦. قابل أي مقطع بطبعة ثانية قبل أن تنضّده.
- يحمل النسخ نفسه أشكالًا لا رسم لها في Noto Serif TC (廹 徳 絶 逺 専 内). خمسة منها تقع داخل نطاق ملف من ملفات Fontsource، فيمرّرها `loadCjkFonts`، ثم يرسمها Canvas من خط النظام، ويطبع postext-pdf مربعًا فارغًا ويحذّر. أما 廹 فلا يقع في نطاق أي ملف، فيتوقف `loadCjkFonts` بخطأ. والنموذج ينضّد 迫 德 絕 遠 專 內.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: 論語集註 (Collected Commentaries on the Analects), juan 1, the book 學而 from its opening to the end of its tenth section, in the copy made for the 四庫全書 (Siku quanshu, 1773–1782); Wikisource transcription, revision 523110. 巳 is read as 已 or 己 by its sentence; 開 in 張敬夫's 必開其政 is corrected to 聞, as in the words of the Analects he repeats and in Wikisource's other edition (論語集注卷一, revision 2598582); the two characters the transcription gives as glyph images are set as 傳 (以忠信為傳習之本) and 邦 (夫子至於是邦也); and six variant forms Noto Serif TC does not draw (廹 徳 絶 逺 専 内) are set in their standard forms: Zhu Xi (1130–1200); transcription by Wikisource editors ([المصدر](https://zh.wikisource.org/w/index.php?title=%E5%9B%9B%E6%9B%B8%E7%AB%A0%E5%8F%A5%E9%9B%86%E8%A8%BB_(%E5%9B%9B%E5%BA%AB%E5%85%A8%E6%9B%B8%E6%9C%AC)/%E8%AB%96%E8%AA%9E%E5%8D%B701&oldid=523110)), CC-BY-SA-4.0
- النص: The title page, the division into leaves and the note on this edition (據四庫全書本錄文…): Ignacio Ferro, CC-BY-4.0
- الخطوط: Noto Serif TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 078 · تعليقات بالحبر الأحمر في السطر وفي الهامش العلوي](https://postext.dev/ar/cookbook/red-ink-commentary.md): صفحة تعليقات صينية عمودية: تعليقات المخطوط الجانبية مطويّة في صفّين أحمرين داخل السطر، وتعليقات الهامش العلوي في طبقة فوق النص. · المستوى 3 (متقدم) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 075 · رواية صينية بالكتابة العمودية، مجلَّدة من اليمين](https://postext.dev/ar/cookbook/vertical-novel-right-bound.md): الفصل 1 من 三國演義 على صفحة 25開 تايوانية: 40 حرفًا نزولًا في 16 عمودًا، وصفحة افتتاح 回目 مسطَّرة، وترويسات على الحافة الخارجية، وأرقام صفحات صينية. · المستوى 3 (متقدم) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 013 · صفحة كتاب على شبكة خطوط الأساس](https://postext.dev/ar/cookbook/baseline-grid-book-page.md): صفحة متناظرة الهوامش بعمودين، كتلة نصها 44 سطرًا من الشبكة بالضبط، فيستقر المتن على شبكة واحدة عبر الفاصل بين العمودين وعبر الكعب. · المستوى 2 (متوسط) · أي نوع
