# اليابانية بالحروف اللاتينية: يوميات تاكوبوكو بالروماجي

> نص بالروماجي موسوم ja-Latn: قواعد يابانية لحروف الكانا فيه، وبلا تقسيم للكلمات اللاتينية، ومدّات خط لاتيني محمولة إلى PDF.

- نسخة HTML: https://postext.dev/ar/cookbook/romaji-nikki
- وصفة رقم 130 · الحروف والنص · المستوى 2 (متوسط) · المخرجات: Canvas, PDF
- الأنواع: الأدب القصصي والمسرح والنثر الأدبي
- تتطلب postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · اختُبرت مع 1.16.1, postext-pdf 1.16.1 بتاريخ 2026-10-05
- الصفحات: [1](https://postext.dev/cookbook/romaji-nikki/en/p01.webp?v=d9887115), [2](https://postext.dev/cookbook/romaji-nikki/en/p02.webp?v=d9887115), [3](https://postext.dev/cookbook/romaji-nikki/en/p03.webp?v=d9887115), [4](https://postext.dev/cookbook/romaji-nikki/en/p04.webp?v=d9887115)
- PDF: https://postext.dev/cookbook/romaji-nikki/en/romaji-nikki.pdf?v=d9887115
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=romaji-nikki&lang=en (.postext: https://postext.dev/cookbook/romaji-nikki/en/romaji-nikki.postext)
- آخر تحديث: 2026-10-05
- لغات أخرى: [en](https://postext.dev/en/cookbook/romaji-nikki.md), [es](https://postext.dev/es/cookbook/romaji-nikki.md), [ca](https://postext.dev/ca/cookbook/romaji-nikki.md), [zh](https://postext.dev/zh/cookbook/romaji-nikki.md), [ja](https://postext.dev/ja/cookbook/romaji-nikki.md)

## باختصار

أول تدوينة من يوميات كتبها الشاعر تاكوبوكو عام 1909 باليابانية بحروف لاتينية، مع قراءتها بالكتابة اليابانية تحت كل فقرة.

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

أربع صفحات من *Rōmaji nikki*، اليوميات التي دوّنها إيشيكاوا تاكوبوكو في طوكيو ربيع 1909 بالحروف اللاتينية كي لا تقرأها زوجته: أسطر العنوان في دفتره والتدوينة الأولى، يوم 7 أبريل، حتى *Kanasii koto da!* تُنضَّد اليوميات طبعةً دراسية على صفحة A5: الروماجي بخط Source Serif 4، مضبوط الطرفين ولا تُقسم كلماته أبدًا، وتحت كل فقرة قراءتها بالكانا والكانجي بخط Noto Serif JP، أصغر وأفتح وبإزاحة. يكتب تاكوبوكو بنظام Nippon-shiki، *si* و*tu* و*hu*، مع علامة الإمالة على الحروف الطويلة، *Tôkyô*؛ وتستعمل أسطر المحرر نظام هيبورن بالمدّات، *Rōmaji*، ويصل الاثنان إلى PDF.

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

- كيف أنضّد اليابانية المكتوبة بالروماجي: أي وسم للغة، وأي تقسيم للكلمات، والمدّات في PDF؟
- ما الخطوط التي تنضّد اليابانية، بالكانجي في أشكاله اليابانية، على الشاشة وفي PDF؟

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

```js
// script.js, سطرًا 35–50
// ja-Latn is Japanese written in Latin script (BCP 47). Its language is ja, so the kana
// under each paragraph get the Japanese rules (kinsoku, 、。 spacing, the space after ！)
// and the built-in strings are Japanese; and Japanese has no hyphenation patterns, so the
// rōmaji is never divided at a line end. English patterns would divide tatinobotta or
// Surigarasu by English syllables, not by the morae a Japanese reader hears.
const language = { locale: 'ja-Latn' }; // spread into the config
const bodyText = {
  fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(1.2), indentAfterHeading: false,
  optimalLineBreaking: true, maxWordSpacing: 1.8, // no hyphens: the spaces take the slack
};
// The reading in kana, under its paragraph: smaller, a shade lighter, indented.
const kana = { id: 'kana', fontFamily: MINCHO, fontSize: pt(8.6), lineHeight: pt(LEAD),
  color: col('kana'), textAlign: 'justify', indent: em(1.5), firstLineIndent: em(1),
  marginTop: pt(LEAD / 4), marginBottom: pt(LEAD * 0.75) };
```

## المكونات

**تعلّم**

- [الكانا والكانجي والروماجي](https://postext.dev/ar/docs/japanese-layout.md#الكانجي-والكانا-والروماجي): تمزج اليابانية أربع كتابات في السطر الواحد: الكانجي والهيراغانا والكاتاكانا والحروف والأرقام اللاتينية (الروماجي). ينضّدها المستند الموسوم ja بلا فراغ بين الكلمات اليابانية وبربع مربع بين اليابانية واللاتينية، ويقيم Ａ１ العريضة في النص العمودي، ويرسم الكانجي بأشكاله اليابانية (locl في الخط) إن كان الخط يابانيًّا مثل Noto Serif JP أو Shippori Mincho؛ أما مدّات الروماجي (ō ū) فتحتاج خطًّا يحويها.
- [تقسيم الكلمات ولغة المستند](https://postext.dev/ar/docs/justification.md#اللغات-المدعومة): تقسيم الكلمات بالواصلة وفق أنماط TeX في ثماني لغات، تُختار بحسب لغة المستند (locale)، التي تحدّد أيضًا عبارات استمرار الجداول ولغة PDF الموسوم.

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

- [الخطوط الصينية واليابانية والكورية](https://postext.dev/ar/docs/configuration.md#خطوط-الصينية-واليابانية-والكورية)
- [تباعد علامات الترقيم اليابانية (yakumono)](https://postext.dev/ar/docs/japanese-layout.md#الياكومونو-بياض-علامات-الترقيم)
- [كسر الأسطر الياباني (kinsoku)](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/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#لوحة-الألوان)
- [التصدير إلى PDF](https://postext.dev/ar/docs/configuration.md#توليد-ملفات-pdf)
- [الخطوط المضمَّنة في PDF](https://postext.dev/ar/docs/configuration.md#لماذا-مزوّد-الخطوط)
- [تقسيم الأسطر في النص الصيني](https://postext.dev/ar/docs/configuration.md#تنضيد-نصوص-شرق-آسيا)
- [فصول بلا أرقام](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)

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

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

**الخطوط**

- Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1), Noto Serif JP (OFL-1.1)

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

### 1 · اوسم النص ja-Latn

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. `ja-Latn` هو وسم BCP 47 لليابانية المكتوبة بالحروف اللاتينية. ولغته `ja`، فتتبع القراءة تحت كل فقرة القواعد اليابانية: الكينسوكو، وتباعد 、。، والفراغ بعرض em الذي يضيفه المحرك بعد ！ داخل الجملة، كما في 「ええ！　ええ！」. ولا أنماط لتقسيم الكلمات في اليابانية، فلا يُقسم الروماجي أبدًا؛ ومع `en` كانت الأنماط الإنجليزية ستقسم كلمات مثل *tatinobotta* أو *Surigarasu* بحسب المقاطع الإنجليزية، لا بحسب المورات التي يسمعها القارئ الياباني. وبلا واصلات تمتص الفراغات الفائض، لذا يبلغ طول السطر نحو 70 حرفًا و`maxWordSpacing` قيمته 1.8 ([تقسيم الكلمات بالواصلة](/ar/docs/configuration#تقسيم-الكلمات-بالواصلة)).

![Page 2: 7TH, WEDNESDAY.](https://postext.dev/cookbook/romaji-nikki/en/p02.webp?v=d9887115)

*الصفحة 2: اليوم، والعنوان، وأول فقرتين بالروماجي وقراءتاهما بالكانا، أصغر وبإزاحة.*

### 2 · احمل المدّات إلى PDF

```js
// script.js, سطرًا 200–202
// Fontsource keeps ō ū ā in a latin-ext file. cjkPdfProvider serves Noto Serif JP from its
// slices, and adds that file after the latin one for a Latin face whose text needs it.
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
```

يحفظ Fontsource الحروف ō ū ā في ملف `latin-ext` منفصل. تحمّله الشاشة (يرى `loadFonts` حرف ō في العيّنة)، ويفعل `cjkPdfProvider` في عدّة الأدوات الشيء نفسه في PDF: يقدّم Noto Serif JP من شرائحه اليابانية، وإذا صفّ خط لاتيني حرفًا لا يوجد إلا في `latin-ext` أجاب بملفين، `latin` ثم `latin-ext`، فيأخذ postext-pdf كل شكل من أول ملف يحويه. أما `fontsourceProvider` البسيط في عدّة الأدوات فلا يسلّم PDF إلا ملف `latin`، ومعه لا يكون في PDF شكل لحرف ō.

### 3 · افتتح بأسطر اليوميات نفسها

```js
// script.js, سطرًا 54–86
const onField = { color: col('paper'), align: 'center', overflow: 'wrap' };
const title = {
  id: 'title', numbered: false, toc: false, span: 'page', runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' }, // its page is an opener: no running heads
  advancedDesign: { enabled: true, minHeight: mm(166), slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('indigo') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill',
        height: 'fill' } } },
    { kind: 'text', id: 'lines', content: '{attr.lines}', ...onField, fontFamily: SERIF,
      fontWeight: 600, fontSize: pt(20), lineHeight: 1.9, letterSpacing: pt(5),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(18) } } },
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(122) },
        size: { width: mm(24) } } },
    { kind: 'text', id: 'note', content: '{attr.note}', inlineMarks: true, ...onField,
      fontFamily: SANS, fontSize: pt(8.5), lineHeight: 1.5, placement: { anchor: { to:
        'container', edge: 'top' }, offset: { y: mm(130) }, size: { width: mm(84) } } },
  ] } },
};
// The entry: its day as a heading, the address under it from an attribute.
const entry = {
  level: 2, fontFamily: SANS, fontWeight: 600, fontSize: pt(10), letterSpacing: pt(2.5),
  color: col('indigo'), marginTop: pt(LEAD), marginBottom: pt(LEAD), advancedDesign: {
    enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [
      { kind: 'text', id: 'day', content: '{titleText}', fontFamily: SANS, fontWeight: 600,
        fontSize: pt(10), letterSpacing: pt(2.5), color: col('indigo'),
        placement: { anchor: { to: 'container', edge: 'top-left' } } },
      { kind: 'text', id: 'place', content: '{attr.place}', fontFamily: SANS, fontSize: pt(7.5),
        letterSpacing: pt(1), color: col('muted'), overflow: 'wrap', align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(LEAD) },
          size: { width: 'fill' } } },
    ] } },
};
```

يبدأ الدفتر بـ NIKKI. I. MEIDI 42 NEN. 1909. APRIL. TOKYO.، بندًا في كل سطر. يأخذها العنوان من سمة، وكل `\n` سطر جديد، وينضّدها بحروف كبيرة متباعدة على مساحة نيلية، قماش غلاف دفتر. وصفحته صفحة افتتاح، فلا تظهر فيها الترويسات.

### 4 · ترويسات بحسب زوجية الصفحة

```js
// script.js, سطرًا 90–99
const head = (id, content, parity, edge) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: SANS, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(edge.endsWith('left') ? 16 : -16),
    y: mm(13) } } });
const header = { elements: [
  head('folio-even', '{pageNumber}', 'even', 'top-left'),
  head('book', 'ROMAZI NIKKI · 1909', 'even', 'top'),
  head('place', 'TOKYO · APRIL', 'odd', 'top'),
  head('folio-odd', '{pageNumber}', 'odd', 'top-right'),
] };
```

تحمل الصفحة الزوجية اسم اليوميات والفردية المكان والشهر، مع أرقام الصفحات في الزوايا الخارجية، بخط Source Sans 3 بحجم 7.5 pt.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 130 · Japanese in Latin letters: Takuboku's Rōmaji Diary ════
// https://postext.dev/en/cookbook/romaji-nikki
// Code: MIT · Text: Ishikawa Takuboku, 1909 (public domain); transcription (CC BY 4.0)
// Fonts: Source Serif 4, Source Sans 3, Noto Serif JP (SIL OFL 1.1) · Needs postext ≥ 1.16.1
// A diary written in Japanese with Latin letters, and its reading in kana under each paragraph.
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the note; the diary is Japanese in both editions
const RECIPE = 'romaji-nikki';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: diary ink, a faded indigo, the cream of a notebook
const palette = {
  ink: '#22201d', // the rōmaji
  indigo: '#34497a', // the date and the title (8.4:1 on the paper)
  kana: '#5a554e', // the transcription, a step lighter than the text (7:1)
  rule: '#c9c1b2',
  muted: '#6f6a62', // running heads, folios, the note
  paper: '#fcfaf4',
};
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: 'indigo (defaults)', value: { hex: palette.indigo, model: 'hex' } },
];
// #endregion

const SERIF = 'Source Serif 4'; // the rōmaji: ô û â ê in latin, ō ū in latin-ext
const SANS = 'Source Sans 3'; // labels, running heads, folios, the note
const MINCHO = 'Noto Serif JP'; // the kana: Noto Serif JP's Latin is Source Serif's
const [BODY, LEAD] = [10.5, 15]; // pt

// #region answer: a Japanese text in Latin letters is tagged ja-Latn
// ja-Latn is Japanese written in Latin script (BCP 47). Its language is ja, so the kana
// under each paragraph get the Japanese rules (kinsoku, 、。 spacing, the space after ！)
// and the built-in strings are Japanese; and Japanese has no hyphenation patterns, so the
// rōmaji is never divided at a line end. English patterns would divide tatinobotta or
// Surigarasu by English syllables, not by the morae a Japanese reader hears.
const language = { locale: 'ja-Latn' }; // spread into the config
const bodyText = {
  fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(1.2), indentAfterHeading: false,
  optimalLineBreaking: true, maxWordSpacing: 1.8, // no hyphens: the spaces take the slack
};
// The reading in kana, under its paragraph: smaller, a shade lighter, indented.
const kana = { id: 'kana', fontFamily: MINCHO, fontSize: pt(8.6), lineHeight: pt(LEAD),
  color: col('kana'), textAlign: 'justify', indent: em(1.5), firstLineIndent: em(1),
  marginTop: pt(LEAD / 4), marginBottom: pt(LEAD * 0.75) };
// #endregion

// #region title: the diary's own heading lines on an indigo field, like a cloth notebook
const onField = { color: col('paper'), align: 'center', overflow: 'wrap' };
const title = {
  id: 'title', numbered: false, toc: false, span: 'page', runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' }, // its page is an opener: no running heads
  advancedDesign: { enabled: true, minHeight: mm(166), slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('indigo') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill',
        height: 'fill' } } },
    { kind: 'text', id: 'lines', content: '{attr.lines}', ...onField, fontFamily: SERIF,
      fontWeight: 600, fontSize: pt(20), lineHeight: 1.9, letterSpacing: pt(5),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(18) } } },
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(122) },
        size: { width: mm(24) } } },
    { kind: 'text', id: 'note', content: '{attr.note}', inlineMarks: true, ...onField,
      fontFamily: SANS, fontSize: pt(8.5), lineHeight: 1.5, placement: { anchor: { to:
        'container', edge: 'top' }, offset: { y: mm(130) }, size: { width: mm(84) } } },
  ] } },
};
// The entry: its day as a heading, the address under it from an attribute.
const entry = {
  level: 2, fontFamily: SANS, fontWeight: 600, fontSize: pt(10), letterSpacing: pt(2.5),
  color: col('indigo'), marginTop: pt(LEAD), marginBottom: pt(LEAD), advancedDesign: {
    enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [
      { kind: 'text', id: 'day', content: '{titleText}', fontFamily: SANS, fontWeight: 600,
        fontSize: pt(10), letterSpacing: pt(2.5), color: col('indigo'),
        placement: { anchor: { to: 'container', edge: 'top-left' } } },
      { kind: 'text', id: 'place', content: '{attr.place}', fontFamily: SANS, fontSize: pt(7.5),
        letterSpacing: pt(1), color: col('muted'), overflow: 'wrap', align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(LEAD) },
          size: { width: 'fill' } } },
    ] } },
};
// #endregion

// #region heads: the diary's name on the verso, the place on the recto, folios outside
const head = (id, content, parity, edge) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: SANS, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(edge.endsWith('left') ? 16 : -16),
    y: mm(13) } } });
const header = { elements: [
  head('folio-even', '{pageNumber}', 'even', 'top-left'),
  head('book', 'ROMAZI NIKKI · 1909', 'even', 'top'),
  head('place', 'TOKYO · APRIL', 'odd', 'top'),
  head('folio-odd', '{pageNumber}', 'odd', 'top-right'),
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  ...language, // ja-Latn, written out (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5
    backgroundColor: col('paper'),
    // A measure of about 70 characters: with no hyphens, a narrower one sets loose lines.
    margins: { top: mm(22), bottom: mm(22), left: mm(17), right: mm(17), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText,
  headings: { fontFamily: SANS, fontWeight: 600, color: col('indigo'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, entry] },
  headingStyles: [title],
  paragraphStyles: [kana,
    { id: 'note', fontFamily: SANS, fontSize: pt(7.8), lineHeight: pt(11.5), color: col('muted'),
      boldColor: col('ink'), boldFontWeight: 600, textAlign: 'left', firstLineIndent: em(0),
      marginTop: pt(2 * LEAD) }],
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "ROMAZI NIKKI"
author: "Isikawa Takuboku"
---

# ROMAZI NIKKI {style="title" lines="NIKKI.\nI.\nMEIDI 42 NEN.\n1909.\nAPRIL.\nTOKYO." note="Ishikawa Takuboku, *Rōmaji nikki* (the Rōmaji Diary), the first entry, 7 April 1909, with a transcription into kana and kanji under each paragraph"}

## 7TH, WEDNESDAY. {place="HONGO-KU MORIKAWA-TYO 1 BANTI, SINSAKA 359 GO, GAIHEI-KAN-BESSO NITE."}

Hareta Sora ni susamajii Oto wo tatete, hagesii Nisi-kaze ga huki areta. Sangai no Mado to yû Mado wa Taema mo naku gata-gata naru, sono Sukima kara wa, haruka Sita kara tatinobotta Suna-hokori ga sara-sara to hukikomu: sono kuse Sora ni tirabatta siroi Kumo wa titto mo ugokanu. Gogo ni natte Kaze wa yô-yô otituita.

:::paragraphs{style="kana"}
晴れた空にすさまじい音を立てて、激しい西風が吹き荒れた。三階の窓という窓は絶え間もなくがたがた鳴る、そのすき間からは、はるか下から立ちのぼった砂ぼこりがさらさらと吹き込む。そのくせ空に散らばった白い雲はちっとも動かぬ。午後になって風はようよう落ち着いた。
:::

Haru rasii Hikage ga Mado no Surigarasu wo atataka ni somete, Kaze sae nakuba Ase de mo nagare sô na Hi de atta. Itu mo kuru Kasihonya no Oyadi, Te-no-hira de Hana wo kosuriage nagara, “Hidoku huki masu nâ.” to itte haitte kita. “Desuga, Kyô-dyû nya Tôkyô-dyû no Sakura ga nokorazu saki masu ze. Kaze ga attatte, anata, kono Tenki de gozai masu mono.”

:::paragraphs{style="kana"}
春らしい日影が窓のすりガラスを暖かに染めて、風さえなくば汗でも流れそうな日であった。いつも来る貸本屋のおやじ、手のひらで鼻をこすり上げながら、「ひどく吹きますなあ。」と言って入って来た。「ですが、今日じゅうにゃ東京じゅうの桜が残らず咲きますぜ。風があったって、あなた、この天気でございますもの。」
:::

“Tôtô Haru ni nattyatta nê!” to Yo wa itta. Muron kono Kangai wa Oyadi ni wakarikko wa nai. “Eh! eh!” to Oyadi wa kotaeta: “Haru wa anata, watasidomo ni wa Kinmotu de gozai masu ne. Kasihon wa, moh, kara Dame de gasu: Hon nanka yomu yorya mata asonde aruita hô ga yô-gasu kara, Muri mo nai-ndesu ga, yonde kudasaru kata mo sizen to kô nagaku bakari narimasunde ne.”

:::paragraphs{style="kana"}
「とうとう春になっちゃったねえ！」と予は言った。むろんこの感慨はおやじにわかりっこはない。「ええ！ええ！」とおやじは答えた。「春はあなた、わたしどもには禁物でございますね。貸本は、もう、からだめでがす。本なんか読むよりゃまた遊んで歩いたほうがようがすから、無理もないんですが、読んでくださる方も自然とこう長くばかりなりますんでね。」
:::

Kinô Sya kara Zensyaku sita Kane no nokori, 5 yen Sihei ga iti-mai Saihu no naka ni aru; Gozen-tyû wa sore bakkari Ki ni natte, Siyô ga nakatta. Kono Kimoti wa, heizei Kane no aru Hito ga kyû ni motanaku natta toki to onaji yô na Kigakari ka mo sirenu: dotira mo okasii koto da, onaji yô ni okasii ni wa tigai nai ga, sono Kô-hukô ni wa taisita Tigai ga aru.

:::paragraphs{style="kana"}
昨日社から前借りした金の残り、五円紙幣が一枚財布の中にある。午前中はそればっかり気になって、しようがなかった。この気持ちは、平生金のある人が急に持たなくなったときと同じような気がかりかもしれぬ。どちらもおかしいことだ、同じようにおかしいには違いないが、その幸不幸には大した違いがある。
:::

Siyô koto nasi ni, Rôma-ji no Hyô nado wo tukutte mita. Hyô no naka kara, toki-doki, Tugaru-no-Umi no kanata ni iru Haha ya Sai no koto ga ukande Yo no Kokoro wo kasumeta. “Haru ga kita, Si-gatu ni natta. Haru ! Haru ! Hana mo saku ! Tokyô e kite mô iti-nen da! ...........Ga, Yo wa mada Yo no Kazoku wo yobiyosete yasinau Junbi ga dekinu !” Tika-goro, Hi ni nankwai to naku, Yo no Kokoro no naka wo atira e yuki, kotira e yuki siteru, Mondai wa kore da ...........

:::paragraphs{style="kana"}
しようことなしに、ローマ字の表などを作ってみた。表の中から、ときどき、津軽の海の彼方にいる母や妻のことが浮かんで予の心をかすめた。「春が来た、四月になった。春！春！花も咲く！東京へ来てもう一年だ！……が、予はまだ予の家族を呼び寄せて養う準備ができぬ！」近ごろ、日に何回となく、予の心の中をあちらへ行き、こちらへ行きしてる問題はこれだ……
:::

Sonnara naze kono Nikki wo Rômaji de kaku koto ni sitaka ? Naze da ? Yo wa Sai wo aisiteru ; aisiteru kara koso kono Nikki wo yomase taku nai no da. ------ Sikasi kore wa Uso da ! Aisiteru no mo Jijitu, yomase taku nai no mo Jijitu da ga, kono Hutatu wa kanarazu simo Kwankei site inai.

:::paragraphs{style="kana"}
そんならなぜこの日記をローマ字で書くことにしたか？なぜだ？予は妻を愛してる。愛してるからこそこの日記を読ませたくないのだ。――しかしこれはうそだ！愛してるのも事実、読ませたくないのも事実だが、この二つは必ずしも関係していない。
:::

Sonnara Yo wa Jakusya ka ? Ina, Tumari kore wa Hûhu-kwankei to yû matigatta Seido ga aru tame ni okoru no da. Hûhu ! nan to yû Baka na Seido darô ! Sonnara dô sureba yoi ka ?

:::paragraphs{style="kana"}
そんなら予は弱者か？否、つまりこれは夫婦関係という間違った制度があるために起こるのだ。夫婦！なんというばかな制度だろう！そんならどうすればよいか？
:::

Kanasii koto da !

:::paragraphs{style="kana"}
悲しいことだ！
:::

:::paragraphs{style="note"}
**A note on the text.** Takuboku kept this diary in Tokyo from 7 April to 16 June 1909, in Latin letters, so that his wife Setsuko could not read it. He spells it in the Nippon-shiki system of 1885, not in Hepburn: *si, ti, tu, hu* for shi, chi, tsu, fu, *dy* for the voiced *t*-row kana (*Kyô-dyû*), a circumflex on long vowels (*Tôkyô*) and, his own habit, a capital on most nouns. The text follows the Iwanami Bunko edition of 1977; the transcription under each paragraph was made for this recipe, in modern kana.

Set in Source Serif 4, Source Sans 3 and Noto Serif JP (SIL OFL) · Diary: public domain · Transcription and note: CC BY 4.0
:::
`; // content.<lang>.md: the same diary in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Source Serif 4': ['400', '400i', '600'], 'Source Sans 3': ['400', '400i', '600'],
  'Noto Serif JP': ['400'] };
const kanaText = (markdown.match(/:::paragraphs\{style="kana"\}[\s\S]*?:::/g) ?? []).join('');

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown); // and the latin-ext files, for the ō of the note
await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, kanaText);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: t({ en: 'The Rōmaji Diary', es: 'El diario en rōmaji' }) });
// #region macrons: the PDF gets each Latin face's latin-ext file too
// Fontsource keeps ō ū ā in a latin-ext file. cjkPdfProvider serves Noto Serif JP from its
// slices, and adds that file after the latin one for a Latin face whose text needs it.
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
// #endregion

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

## تنويعات

### اكتب الروماجي بنظام هيبورن

قد تعيد طبعة حديثة كتابة اليوميات بنظام هيبورن بالمدّات، *Tōkyō* بدل *Tôkyô*. يحمل PDF حرف ō أصلًا، فلا يتغير إلا النص؛ وأضف ملاحظة تقول إن الإملاء من عمل المحرر.

### قسّم بين المورات

إن احتاج عمود ضيق إلى واصلات، فضع واصلات لينة (U+00AD) بين مورات الروماجي، `Su­na-ho­ko­ri` في الشيفرة أو U+00AD مكتوبة في النص: المستند الموسوم `ja-Latn` لا يقسم الكلمات من تلقاء نفسه، لكنه يكسر عند الواصلة اللينة إذا لم تتسع الكلمة.

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

- **اوسم النص الياباني بـ '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() قبل إعادة البناء إذا تأخر وصول أحدها.

- ليس في Shippori Mincho حرفا ō وū؛ أما Noto Serif JP وSource Serif 4 ففيهما في ملفات `latin-ext`. افحص الخط قبل أن تختاره لنص بنظام هيبورن.
- الصينية بالحروف اللاتينية هي البينيين، وتُنضَّد ruby فوق الحروف في [كتاب قراءة أول بالبينيين فوق كل حرف](https://postext.dev/ar/cookbook/pinyin-primer.md)؛ أما روماجي تاكوبوكو فهو النص نفسه، فيُنضَّد نصًّا لاتينيًّا بتباعده الخاص، لا تعليقًا.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: Ishikawa Takuboku, ROMAZI NIKKI (the Rōmaji Diary), the entry of 7 April 1909 to “Kanasii koto da!”, in Latin letters as he wrote it, after the Iwanami Bunko edition (ed. Kuwabara Takeo, 1977) as transcribed on the blog 啄木の息: Ishikawa Takuboku (1886–1912) ([المصدر](https://takuboku-no-iki.hatenablog.com/entry/20130519/p1)), ملكية عامة
- النص: The transcription into kana and kanji under each paragraph and the note on the text, written for this recipe: Postext Cookbook, CC-BY-4.0
- الخطوط: Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1), Noto Serif JP (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 083 · قصائد تانغ مقابل نظمها الإنجليزي](https://postext.dev/ar/cookbook/tang-poems-facing-english.md): تفتح كل قصيدة من عصر تانغ صفحة فردية ملوّنة، ويفتح نظم Giles الإنجليزي الصفحة الزوجية المقابلة: نمط عنوان يقفز إلى صفحة زوجية والآخر إلى فردية. · المستوى 2 (متوسط) · الشعر
- [رقم 104 · رواية قصيرة كتالونية بتقسيم على قواعد IEC](https://postext.dev/ar/cookbook/catalan-pocket-novella.md): رواية El transplantat لنارسيس أوليه كاملة في كتاب جيب من 32 صفحة: مقاطع كتالونية على قواعد IEC، وl·l تُقسم il- | lusió، ونقش لكل فصل. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 076 · كتاب قراءة بالبينيين: النطق فوق كل حرف](https://postext.dev/ar/cookbook/pinyin-primer.md): روبي أحادي في كتاب قراءة أول من هونغ كونغ: {人之初|rén zhī chū} يضع مقطع بينيين واحدًا في الوسط فوق كل حرف، بخط Andika، داخل فجوة أسطر من 28 نقطة. · المستوى 2 (متوسط) · الكتب المدرسية, كتب التمارين والتدريبات
