# الأعداد الكانجية والعدّادات والتواريخ في النص العمودي

> نشرة عمودية تكتب كل عدد كما تكتبه اليابانية: 百二十 بالعدّ، و二〇二六 رقمًا رقمًا، و壱萬弐阡 بالـ daiji، وعدّادات （一） イ あ، و8月22日 قائمًا.

- نسخة HTML: https://postext.dev/ar/cookbook/kanji-numerals-and-dates
- وصفة رقم 129 · الحروف والنص · المستوى 2 (متوسط) · المخرجات: Canvas, PDF
- الأنواع: الجرائد والنشرات
- تتطلب postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · اختُبرت مع 1.16.1, postext-pdf 1.16.1 بتاريخ 2026-10-05
- الصفحات: [一](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p01.webp?v=cdc00c87), [二](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p02.webp?v=cdc00c87), [三](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p03.webp?v=cdc00c87), [四](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p04.webp?v=cdc00c87)
- PDF: https://postext.dev/cookbook/kanji-numerals-and-dates/en/kanji-numerals-and-dates.pdf?v=cdc00c87
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=kanji-numerals-and-dates&lang=en (.postext: https://postext.dev/cookbook/kanji-numerals-and-dates/en/kanji-numerals-and-dates.postext)
- آخر تحديث: 2026-10-05
- لغات أخرى: [en](https://postext.dev/en/cookbook/kanji-numerals-and-dates.md), [es](https://postext.dev/es/cookbook/kanji-numerals-and-dates.md), [ca](https://postext.dev/ca/cookbook/kanji-numerals-and-dates.md), [zh](https://postext.dev/zh/cookbook/kanji-numerals-and-dates.md), [ja](https://postext.dev/ja/cookbook/kanji-numerals-and-dates.md)

## باختصار

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

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

أربع صفحات عمودية من نشرة حيّ، 東若葉台だより، العدد 第百二十号، على صفحة B5 مجلدة على اليمين: إعلان مهرجان الصيف، وتقويم أغسطس، والاشتراكات مع إيصال تبرع، وتاريخ موجز للجمعية. الجمعية متخيَّلة، لكن أعدادها مكتوبة كما يكتبها محرر ياباني. رقم العدد، و第三十二回 للمهرجان، و二千四百人 الذين حضروه تُعدّ بـ 十 و百 و千؛ والسنوات تُكتب رقمًا رقمًا، 二〇二六年 و一九六〇年؛ ومبلغ الإيصال بالأعداد الرسمية (大字, daiji)، 壱萬弐阡، التي لا تُحرَّف بخط واحد؛ والأوقات والتواريخ بأرقام عربية، ويقوم 22 في خانة واحدة. وتنزل القوائم 一、 → （一） → イ → あ، والتقويم في الغلاف الخلفي.

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

- كيف أكتب الأعداد في اليابانية العمودية: 二千二十六 أم 二〇二六، و壱萬 على إيصال، و8月22日 قائمًا؟
- لماذا يخرج نصّي الياباني بالقواعد الصينية: تسمية 图، وكانا صغيرة في أول الأسطر، وكانجي بأشكال صينية؟

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

```js
// script.js, سطرًا 44–65
// formatNumeral is the counter the engine numbers pages, lists and headings with.
const ISSUE = 120;
const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted
const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money
// {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji.
const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n)));
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
};
// Sections 一、二、三、, items （一）（二）, then イ ロ ハ and あ い う beneath them.
const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '',
  fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り
const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix,
  separator }); // each level starts where the text of the one above starts
const orderedLists = {
  fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5),
  marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
  levels: [level(1, 'japanese-informal', '（', '）'), level(2, 'katakana-iroha', '', ''),
    level(3, 'hiragana', '', '、')],
};
```

## المكونات

**تعلّم**

- [الأعداد اليابانية وعدّادات الكانا](https://postext.dev/ar/docs/japanese-layout.md#الأعداد-والعدّادات): عدّادات بالأساليب اليابانية: japanese-informal (百一، 二千二十六، بلا 一 قبل 十 百 千)، وjapanese-formal (壱 弐 参 拾)، والهيراغانا والكاتاكانا (あいう، アイウ)، وترتيبا iroha (いろは، イロハ). في المستند الموسوم ja يُقرأ الرمز 一 على أنه japanese-informal، فيطبع 第{1:一}章 العنوان 第十二章؛ وتختار あ وア وい وイ و壱 الأساليب الأخرى في أرقام الصفحات والقوائم والحواشي وعدّادات الموارد.
- [أرقام قائمة في النص العمودي](https://postext.dev/ar/docs/configuration.md#الأرقام-في-النص-العمودي): في النص العمودي يقف الرقم الذي لا تتجاوز خاناته cjk.uprightDigits (اثنتان افتراضيًا) قائمًا في خلية واحدة، وهو ما يسمى tate-chu-yoko؛ ويضع :tcy[…] أي مقطع قصير في خلية واحدة، ويقيم :upright[…] كل حرف من الاختصار، ويدير :sideways[…] المقطع مع اتجاه السطر.
- [القوائم المرقّمة](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/japanese-layout.md#الكانجي-والكانا-والروماجي)
- [الخطوط الصينية واليابانية والكورية](https://postext.dev/ar/docs/configuration.md#خطوط-الصينية-واليابانية-والكورية)
- [تباعد علامات الترقيم اليابانية (yakumono)](https://postext.dev/ar/docs/japanese-layout.md#الياكومونو-بياض-علامات-الترقيم)
- [عناوين تشغل أسطرًا من المتن (gyōdori)](https://postext.dev/ar/docs/japanese-layout.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#أنواع-الموارد)
- [لون الورق](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#أنماط-الفقرات)
- [تقسيم الأسطر في النص الصيني](https://postext.dev/ar/docs/configuration.md#تنضيد-نصوص-شرق-آسيا)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [أشكال في هذا الموضع بالضبط](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)
- [الترويسات بحسب دور الصفحة](https://postext.dev/ar/docs/configuration.md#عناصر-النص)
- [الخطوط المضمَّنة في PDF](https://postext.dev/ar/docs/configuration.md#لماذا-مزوّد-الخطوط)
- [ترقيم روماني للصفحات التمهيدية](https://postext.dev/ar/docs/document-format.md#numbering)
- [فصول بلا أرقام](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)

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

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`captionStyle`](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#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`tableStyle`](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), `formatNumeral`, `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية), [`renderToPdf`](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-pdf)

**الخطوط**

- Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1), Zen Maru Gothic (OFL-1.1)

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

### 1 · اختر الكتابة بحسب موضع العدد

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. `formatNumeral` هو العدّاد الذي يرقّم به المحرك الصفحات والقوائم والعناوين، وتستدعيه الوصفة للأعداد التي تركّبها بنفسها: `japanese-informal` يعدّ (120 → 百二十، بلا 一 قبل 十 و百 و千)، و`cjk-decimal` يكتب رقمًا رقمًا (2026 → 二〇二六)، و`japanese-formal` يعطي الصيغة الرسمية للمبلغ (12000 → 壱萬弐阡). ويصير `{{daiji:12000}}` في النص مبلغ الإيصال، فيُكتب العدد مرة واحدة بالأرقام. والأساليب نفسها تسمّي العدّادات: الأقسام `{2:一}、`، ويضبط `orderedLists.levels` الصيغة （一）، ثم `katakana-iroha` (イ ロ ハ)، ثم `hiragana` مع 、 (あ、い、う). ويقيم `cjk.uprightDigits: 2` التاريخين 8月22日 و23日 بلا ترميز، ويتقاسم `!?` في 「雨が降ったら中止!?」 خانة واحدة، وهذا سلوك افتراضي في اليابانية ([الأرقام في النص العمودي](/ar/docs/configuration#الأرقام-في-النص-العمودي)).

![Page 二: 二、八月の行事.](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p02.webp?v=cdc00c87)

*الصفحة 2: イ ロ ハ تحت （三）، وあ、い、う تحت ハ، و23日 و4時 قائمان، و!? في خانة واحدة.*

### 2 · ضع الترويسة في شريط على الحافة اليمنى

```js
// script.js, سطرًا 69–92
const BAND = 46; // mm from the right trim edge
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right
const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' };
const masthead = {
  id: 'masthead', numbered: false, toc: false, runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND,
      fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3),
      placement: at(HEAD, 9) },
    { kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC,
      fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) },
    { kind: 'text', id: 'date', content: `${pos(2026)}年（令和${kan(8)}年）${kan(8)}月${kan(1)}日発行`,
      ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right',
      placement: at(HEAD, 26, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(7.5), placement: at(HEAD, 40) },
  ] } },
};
```

في الصفحة العمودية يكون أعلى التدفق هو الحافة اليمنى للورقة، فتصميم العنوان المثبت إلى أعلى النزف شريطٌ على تلك الحافة، و`size.height` الخاص به هو عرضه على الورقة. ورقم العدد والتاريخ نص تصميم مبني بـ `kan()` و`pos()`، وينتهي التاريخ عند أسفل مساحة النص بـ `align: 'right'`.

### 3 · أعطِ التقويم صفحة خاصة به

```js
// script.js, سطرًا 96–112
const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会',
  22: '夏祭り', 23: '夏祭り', 30: '役員会' };
const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center',
  ...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) }
  : { content: '' });
const weeks = []; // 1 August 2026 is a Saturday
for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0
  && d + k <= 31 ? d + k : 0)));
const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  // An upright table takes every line of the vertical page it stands on (gap:
  // vertical-tables): the calendar is the back page, cited at the end of the text.
  placement: { position: 'top', span: 'page' },
  caption: '八月の行事。13日から16日まではごみの収集がありません。',
  table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c,
    isHeader: true, align: 'center' })), ...weeks] } } };
const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表',
  numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二…
```

يقوم الجدول في الصفحة العمودية ويُقرأ أفقيًا، كما تطبع الكتب اليابانية جداولها؛ ويأخذ كل أسطر الصفحة التي يقع فيها. يُستشهد بالتقويم بعد آخر فقرة كعنصر عائم بعرض الصفحة، فيصير الغلاف الخلفي ويملأ النص الذي قبله صفحاته. ويرقّمه `resourceTypes` بالرمز `一` (表一)، ويضع مستند `ja` التسمية 表一　 من تلقاء نفسه: التسمية والرقم متصلان وبعدهما مسافة إيديوغرافية.

### 4 · حمّل كل صوت بالنص الذي ينضّده

```js
// script.js, سطرًا 243–245
const heads = (markdown.match(/^#+ .*$/gm) ?? []).join('');
const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${
  calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証（記入例）表一`;
```

ينضّد Noto Serif JP النص، وNoto Sans JP العناوين والقوائم والتقويم وتسمية الإيصال، وZen Maru Gothic العنوان وحده. يحمّل كل خط ملفات Fontsource لحروفه هو، إضافة إلى الأعداد التي تركّبها الوصفة.

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

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

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/kanji-numerals-and-dates

### script.js

```js
// ═══ Postext Cookbook · Nº 129 · Kanji numerals, counters and dates in vertical text ═══
// https://postext.dev/en/cookbook/kanji-numerals-and-dates
// Code: MIT · Text: written for the recipe (CC BY 4.0)
// Fonts: Noto Serif JP, Noto Sans JP, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.16.1
// A neighbourhood newsletter set vertically: every way Japanese writes a number.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
  formatNumeral,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the newsletter is Japanese in both
const RECIPE = 'kanji-numerals-and-dates';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, the green of young leaves, its tint, a festival red and its blush
const palette = {
  ink: '#1f2420', // the text
  leaf: '#2f6b3f', // masthead band, section numbers (6.4:1 on the paper)
  tint: '#e4efdf', // the calendar's marked days, the receipt
  red: '#b4362b', // the receipt's amount
  blush: '#f4d4cc', // the festival days in the calendar
  muted: '#5f665f', // folios, the colophon
  paper: '#fdfdf9',
};
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: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } },
];
// #endregion

const MINCHO = 'Noto Serif JP'; // the text
const GOTHIC = 'Noto Sans JP'; // section heads, labels, the calendar
const ROUND = 'Zen Maru Gothic'; // the masthead's title
const [BODY, LEAD] = [11, 20]; // pt: large print for every age on the street
const [CHARS, LINES] = [44, 20];
const PT = 25.4 / 72; // mm in a point
const TRIM = { w: 182, h: 257 }; // mm, B5
const LINE = CHARS * BODY * PT; // mm: 170.7, the length of a line
const HEAD = 30 + (TRIM.h - 30 - 24 - LINE) / 2; // mm: the head of the type area

// #region answer: one number, four spellings, chosen by where it stands
// formatNumeral is the counter the engine numbers pages, lists and headings with.
const ISSUE = 120;
const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted
const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money
// {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji.
const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n)));
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
};
// Sections 一、二、三、, items （一）（二）, then イ ロ ハ and あ い う beneath them.
const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '',
  fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り
const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix,
  separator }); // each level starts where the text of the one above starts
const orderedLists = {
  fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5),
  marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
  levels: [level(1, 'japanese-informal', '（', '）'), level(2, 'katakana-iroha', '', ''),
    level(3, 'hiragana', '', '、')],
};
// #endregion

// #region masthead: a green band down the right edge, the issue and the date in kanji
const BAND = 46; // mm from the right trim edge
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right
const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' };
const masthead = {
  id: 'masthead', numbered: false, toc: false, runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND,
      fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3),
      placement: at(HEAD, 9) },
    { kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC,
      fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) },
    { kind: 'text', id: 'date', content: `${pos(2026)}年（令和${kan(8)}年）${kan(8)}月${kan(1)}日発行`,
      ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right',
      placement: at(HEAD, 26, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(7.5), placement: at(HEAD, 40) },
  ] } },
};
// #endregion

// #region calendar: August 2026 as a table, the festival and the duties marked
const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会',
  22: '夏祭り', 23: '夏祭り', 30: '役員会' };
const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center',
  ...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) }
  : { content: '' });
const weeks = []; // 1 August 2026 is a Saturday
for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0
  && d + k <= 31 ? d + k : 0)));
const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  // An upright table takes every line of the vertical page it stands on (gap:
  // vertical-tables): the calendar is the back page, cited at the end of the text.
  placement: { position: 'top', span: 'page' },
  caption: '八月の行事。13日から16日まではごみの収集がありません。',
  table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c,
    isHeader: true, align: 'center' })), ...weeks] } } };
const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表',
  numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二…
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(30), bottom: mm(24), left: mm(16), right: mm(18), mirror: true },
    pageNumbering: { format: 'japanese-informal' },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk,
  bodyText: {
    fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  },
  headings: { fontFamily: GOTHIC, color: col('ink'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, sectionLevel] },
  headingStyles: [masthead],
  orderedLists,
  paragraphStyles: [
    { id: 'item', firstLineIndent: em(0), indent: em(1), hangingIndent: em(3) },
    { id: 'chronicle', firstLineIndent: em(0), hangingIndent: em(14) },
    { id: 'colophon', fontFamily: GOTHIC, fontSize: pt(7), color: col('muted'),
      textAlign: 'left', firstLineIndent: em(0), marginTop: pt(LEAD) },
  ],
  calloutStyles: [{ id: 'receipt', title: '領収証（記入例）', background: col('tint'),
    padding: { top: mm(4), right: mm(4), bottom: mm(4), left: mm(4) },
    marginTop: pt(LEAD / 2), marginBottom: pt(0), // the next heading's 行取り gives it room
    titleStyle: { fontFamily: GOTHIC, fontSize: pt(12), fontWeight: 700, color: col('leaf') },
    body: { fontFamily: MINCHO, color: col('ink'), boldColor: col('red'), textAlign: 'left',
      firstLineIndent: em(0) } }],
  tableStyle: { rules: 'horizontal', borderColor: col('leaf'), borderWidth: pt(0.5),
    headerBackground: col('leaf'), headerColor: col('paper'), headerFontFamily: GOTHIC,
    headerFontSize: pt(12), bodyFontFamily: GOTHIC, bodyFontSize: pt(11), bodyColor: col('ink'),
    cellPadding: mm(3.2) },
  // 表一　八月の行事: label and number solid, then U+3000 (the ja default)
  captionStyle: { fontFamily: GOTHIC, fontSize: pt(10), color: col('ink'),
    labelColor: col('leaf') },
  header: { elements: [{ kind: 'text', id: 'folio', content: '{pageNumber}', pages: 'body',
    fontFamily: GOTHIC, fontSize: pt(8), color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } }] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = withDaiji(String.raw`---
title: "東若葉台だより"
author: "東若葉台二丁目町会　広報部"
---

# 東若葉台だより {style="masthead" latin="Higashi-Wakabadai News, the newsletter of a neighbourhood association (fiction)"}

暑い日が続いています。今号は夏祭りの案内を中心に、八月の行事、町会費と協賛金のお願い、それに町会の歩みをお届けします。

## 夏祭りのお知らせ

今年の夏祭りは第三十二回になります。平成七年に公民館の駐車場で始まった小さな盆踊りが、昨年は二日間で延べ二千四百人が訪れる行事になりました。今年も子ども会、婦人部、消防団の皆さんが準備を進めています。

:::paragraphs{style="item"}
日時　8月22日（土）・23日（日）　午後4時から9時まで

会場　若葉台第二公園（雨の場合は公民館ホール）
:::

1. 盆踊り
   1. 練習会　8月18日（火）・19日（水）の午後7時から、公民館ホールで行います。浴衣でなくてもかまいません。
   2. 本番　両日とも午後6時半から。太鼓は今年も若葉台中学校の和太鼓部が務めます。
2. 屋台
   1. 子ども会　焼きそば、かき氷
   2. 婦人部　おでん、冷やしきゅうり
   3. 消防団　ラムネ、射的
3. 子ども神輿
   1. 対象　小学生。保護者の方の付き添いをお願いします。
   2. 出発　23日（日）午後4時、公民館前。若葉通りを回り、5時半に戻ります。
   3. 持ち物
      1. 帽子
      2. 水筒
      3. タオル

「雨が降ったら中止!?」という問い合わせを、毎年この時期にいただきます。小雨なら公園で行い、強い雨のときは公民館ホールに移します。中止にするのは警報が出たときだけで、その場合は当日の正午までに町会の掲示板でお知らせします。

## 八月の行事

八月の行事は、裏表紙の表一にまとめました。お盆の13日から16日までは、ごみの収集がありません。15日の朝、集積所にごみを出さないようにお願いします。

## 町会費と協賛金のお願い

今年度の町会費は、一世帯あたり年額三千六百円です。九月の班長会までに、各班の班長さんへお納めください。

夏祭りには、毎年たくさんの商店と個人の方から協賛金をいただいています。協賛金には領収証をお渡しします。金額は、書き換えを防ぐために大字（だいじ）で書きます。

:::callout{type="receipt"}
若葉台商店会　様

金　**{{daiji:12000}}**　円也

但し　第三十二回夏祭り協賛金として

二〇二六年八月一日　東若葉台二丁目町会　会計　中村　節子
:::

## 町会の歩み

:::paragraphs{style="chronicle"}
一九六〇年（昭和三十五年）　町会ができる。このときの世帯数は八十四。

一九七二年（昭和四十七年）　公民館が完成する。

一九九五年（平成七年）　　　第一回の夏祭りを公民館の駐車場で開く。

二〇一一年（平成二十三年）　若葉台第二公園に防災倉庫を置く。

二〇二六年（令和八年）　　　世帯数は三百十二になった。
:::

:::paragraphs{style="colophon"}
Set in Noto Serif JP, Noto Sans JP and Zen Maru Gothic (SIL OFL) · The association, its people and its events are fiction written for this recipe, CC BY 4.0
:::

::resource{id="calendar"}
`); // content.<lang>.md, amounts filled in

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Noto Serif JP': ['400', '700'], 'Noto Sans JP': ['400', '700'],
  'Zen Maru Gothic': ['700'] };
// #region voices: each face loads the files of the characters it sets
const heads = (markdown.match(/^#+ .*$/gm) ?? []).join('');
const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${
  calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証（記入例）表一`;
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, markdown, { vertical: true });
await loadCjkFonts({ [GOTHIC]: FONTS[GOTHIC] }, gothicText, { vertical: true });
await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, heads, { vertical: true });
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources: [calendar] }, config()), markdown);
showBook(doc, { title: t({ en: 'A newsletter in kanji numerals',
  es: 'Un boletín con numerales kanji' }) });
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 gets the latin file fontsourceProvider fetches (the "pdf" block)
 *  and, when the face sets letters only latin-ext has, that file too. */
async function cjkPdfProvider(family, weight, style, request) {
  if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request);
  const meta = await fontsourceMeta(family);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const slices = await cjkSlices(family, w, s);
  const picked = new Set();
  for (const cp of request?.codePoints ?? []) {
    const slice = cjkSliceFor(slices, cp);
    if (slice) picked.add(slice);
  }
  if (!picked.size) picked.add(slices[0]);
  return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => {
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
}

/** A Latin family set next to the CJK faces: its latin file, then its
 *  latin-ext file when the face sets letters only latin-ext has (ō ū in
 *  Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for
 *  them. Latin comes first: postext-pdf draws a character from the first
 *  file that has it, as the browser takes a character both files hold from
 *  latin. A face Fontsource ships without latin-ext, or whose file does
 *  not come, gets latin alone, and the PDF names the letters it lacks. */
async function cjkLatinPdfFiles(family, weight, style, request) {
  const meta = await fontsourceMeta(family);
  const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly);
  if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const id = fontsourceId(family);
  const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`;
  const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url)
    .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]);
  return ext ? [latin, ext] : latin;
}

/** Whether code point `cp` is in Fontsource's latin-ext file and not in
 *  its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and
 *  Latin Extended Additional (loadFonts's test for latin-ext), less the
 *  few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */
function cjkLatinExtOnly(cp) {
  if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false;
  return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp);
}

/** showPages for a book bound on either edge. A right-bound book (the
 *  document says so: doc.binding is 'right' for page.binding 'right' and
 *  for vertical text) lies on the desk as it opens: page 1 alone on the
 *  left of the spine, then [3 | 2], the spine shade on each page's inner
 *  edge. `binding` ('left' | 'right') overrides the document's. */
function showBook(docs, { binding, ...options } = {}) {
  const count = showPages(docs, options);
  const right = (binding ?? [docs].flat()[0]?.binding) === 'right';
  if (!document.getElementById('pt-kit-cjk')) {
    // The pages keep direction ltr: a canvas draws text in the direction its
    // element inherits, and under rtl each run would end where the engine
    // starts it, its brackets mirrored.
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk">
      .pt-spread[dir="rtl"] canvas { direction: ltr; }
      .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18),
        0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
    </style>`);
  }
  // Each pair stays [verso, recto] in the page; right to left, the verso
  // sits on the right. Phones stack the pages in reading order either way.
  for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr';
  document.getElementById('pages').dataset.binding = right ? 'right' : 'left';
  return count;
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### اكتب كل السنوات بالعدّ

يفضّل بعض الكتّاب 二千二十六年 على 二〇二六年 في النص الجاري؛ غيّر أسلوب `pos`.

```diff
-const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
+const pos = (n) => formatNumeral(n, 'japanese-informal'); // 2026 → 二千二十六
```

### أقم الأعداد ذات الثلاثة أرقام

مع 3 يأخذ 100 أو 365 في النص خانة واحدة أيضًا.

```diff
-  uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
+  uprightDigits: 3,
```

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

- **اوسم النص الياباني بـ 'ja'، لا بـ zh-Hans ولا بـ LANG.** نسختا الوصفة هما en وes، لكن المثال الياباني ياباني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، والوسم الصيني سينضّده بالقواعد الصينية (ترقيم Kaiming، وكانا صغيرة يجوز أن تبدأ بها الأسطر، و图 بدل 図، وأشكال صينية للحروف في PDF). اكتب 'ja': يختار منطقة اليابان (كسر الأسطر والترقيم وفق JLReq، ونقاط السمسم، وتوزيع الفوريغانا، وتسميتي 図 و表) ويوقف تقسيم الكلمات. ويُفشل الفحصُ نصًّا فيه كانا موسومًا بـ zh أو ko.
- **انضد اليابانية بخط ياباني.** في Noto Serif SC وTC كانا، لكنهما ترسمان الكانجي بأشكال صينية (يختلف 直 و骨 و角) والكانا بتصميم صيني. انضد النص بـ Noto Serif JP أو Shippori Mincho B1 والعناوين بـ Noto Sans JP، محمَّلة بـ loadCjkFonts. لا تحوي ملفات Fontsource اليابانية الهينتايغانا ولا غيرها من الكانا التاريخية (U+1B000–1B16F): يفشل loadCjkFonts عندها ويطبع PDF مربعات، فاكتب الكانا الحديثة أو ضع في أصول الوصفة خطًّا يحويها. ولا يحوي Shippori Mincho أيضًا الصائتين ō وū بالمدّة: انضد الروماجي بخط لاتيني.
- **الخطوط الصينية تُحمَّل شرائح، عبر الكتلة cjk.** يقدّم Fontsource العائلة الصينية أو اليابانية أو الكورية في نحو مئة ملف لكل وزن، يغطي كل منها نطاقًا من الحروف. لا يجلب loadFonts إلا ملف latin، فتأتي حروف الهان على الشاشة من خط النظام وتُقاس خطأً، ويسلّم fontsourceProvider ملف latin ذاك إلى PDF، فيطبعها مربعات فارغة. اذكر كتلة الأدوات cjk، واستدعِ loadCjkFonts(FONTS, markdown) بعد loadFonts (مرة لكل صوت، مع النص الذي يضعه، حين يستخدم الكتاب عدة خطوط CJK)، وأعطِ renderToPdf القيمة fontProvider: cjkPdfProvider: كلاهما يأخذ الملفات التي تحتوي حروف النص.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

- يشغل التقويم صفحة كاملة لأن الجدول القائم يأخذ كل أسطر الصفحة العمودية (فجوة: الجداول في النص العمودي). والجدول الذي يجب أن يشارك النص صفحته يحتاج صفحة أفقية، أو رسمًا SVG يضعه تصميم عنوان.
- يكتب العدّاد نفسه في الصينية 一百二十 و一千零一، وفي اليابانية 百二十 و千一. اكتب `locale: 'ja'` وإلا اختار الرمز 一 الأسلوب الصيني. وينضّد القارئ الصيني [تواريخ واختصارات قائمة في النص العمودي](https://postext.dev/ar/cookbook/chinese-dates-upright.md) تواريخه بـ `uprightDigits` نفسه.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: The newsletter of a fictional neighbourhood association, written in Japanese for this recipe: Postext Cookbook, CC-BY-4.0
- الخطوط: Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1), Zen Maru Gothic (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 081 · تواريخ واختصارات قائمة في النص العمودي](https://postext.dev/ar/cookbook/chinese-dates-upright.md): وثيقتان تأسيسيتان من 1912 منضدتان عموديًا بتجليد على اليمين: الأعداد ذات الرقمين تقوم وحدها في خانة واحدة، ويعلّم :tcy و:upright الباقي. · المستوى 2 (متوسط) · الكتب المدرسية, الأوراق المفردة والمطبوعات العابرة
- [رقم 082 · مستند رسمي صيني وفق GB/T 9704](https://postext.dev/ar/cookbook/chinese-official-document.md): إشعار من أربع صفحات A4 وفق المعيار الوطني: شبكة 28 × 22 حرفًا بحجم 三号، وترويسة حمراء، وعناوين 一、（一）1.（1）، وملحق، وأرقام صفحات «— 1 —». · المستوى 2 (متوسط) · التقارير
- [رقم 084 · صفحة جريدة بكتابة عمودية في طبقات](https://postext.dev/ar/cookbook/vertical-newspaper-tiers.md): صفحتان من جريدة صينية من عام 1912 منضّدتان عموديًا: المتن في طبقتين بينهما خط، والبرقيات في ثلاث، والعنوان الرئيسي ممتد على الطبقتين. · المستوى 2 (متوسط) · الجرائد والنشرات
