# Notes al costat d'un text japonès vertical, en el plec

> El primer capítol de Jocs d'infants d'Ichiyō amb set notes com a 傍注: （1） a la dreta de la línia i les notes del plec al tall de la pàgina de l'esquerra.

- Versió HTML: https://postext.dev/ca/cookbook/vertical-notes-and-sidenotes
- Recepta Núm. 121 · Requadres i notes · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Narrativa, teatre i prosa literària
- Requereix postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · provada amb 1.16.1, postext-pdf 1.16.1 el 2026-10-05
- Pàgines: [一](https://postext.dev/cookbook/vertical-notes-and-sidenotes/es/p01.webp?v=756d60c3), [二](https://postext.dev/cookbook/vertical-notes-and-sidenotes/es/p02.webp?v=756d60c3), [三](https://postext.dev/cookbook/vertical-notes-and-sidenotes/es/p03.webp?v=756d60c3), [四](https://postext.dev/cookbook/vertical-notes-and-sidenotes/es/p04.webp?v=756d60c3)
- PDF: https://postext.dev/cookbook/vertical-notes-and-sidenotes/es/vertical-notes-and-sidenotes.pdf?v=756d60c3
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=vertical-notes-and-sidenotes&lang=es (.postext: https://postext.dev/cookbook/vertical-notes-and-sidenotes/es/vertical-notes-and-sidenotes.postext)
- Última actualització: 2026-10-05
- Altres idiomes: [en](https://postext.dev/en/cookbook/vertical-notes-and-sidenotes.md), [es](https://postext.dev/es/cookbook/vertical-notes-and-sidenotes.md), [zh](https://postext.dev/zh/cookbook/vertical-notes-and-sidenotes.md), [ja](https://postext.dev/ja/cookbook/vertical-notes-and-sidenotes.md), [ar](https://postext.dev/ar/cookbook/vertical-notes-and-sidenotes.md)

## En poques paraules

Un conte clàssic japonès amb explicacions breus dels seus vells topònims. Els números petits van al costat del text, i les explicacions de les dues pàgines obertes s'apleguen a la vora exterior de la pàgina de l'esquerra.

## Què compondràs

Quatre pàgines d'una edició 四六判 de *Jocs d'infants* (たけくらべ, 1895–96) de Higuchi Ichiyō, el seu primer capítol, amb set notes sobre el vell barri de Yoshiwara que descriu. Les notes són 傍注 (bōchū), les notes laterals dels clàssics japonesos de butxaca: un （1） petit va a la dreta de la línia després de la paraula que explica, i les notes citades a les dues pàgines d'un plec es componen al tall de la pàgina de l'esquerra, sota un filet d'un terç de línia, en un cos una cinquena part més petit. El lector troba cada nota sense girar full. El text, de 42 caràcters per 17 línies en Shippori Mincho de 9,25 pt, conserva l'únic paràgraf llarguíssim d'Ichiyō i les seves marques de sèsam sobre ぎつちよんちよん. Les notes al peu de columna d'una novel·la europea són la [Núm. 071](https://postext.dev/ca/cookbook/novel-footnotes-column-foot.md).

**Aquesta recepta respon a:**

- Com componc les notes d'un llibre japonès vertical al costat del text, en el plec?
- Com faig notes a peu de pàgina?
- Com marco un text japonès amb punts de sèsam o una línia lateral en lloc de cursiva?

## La resposta curta

```js
// script.js, línies 32–48
// placement 'spread' gathers the notes cited on the two pages of a spread at the end of the
// left-hand page's text, the fore-edge in a book bound on the right, under a rule a third of
// the line long, and numbers them per spread. The marker （1） stands small at the right of
// the line, its digits upright, and the note opens with its number and a full note-em.
const footnotes = {
  placement: 'spread', // 傍注; 'chapterEnd' sets 後注 after the chapter instead
  numbering: 'spread',
  markerPosition: 'right', markerTemplate: '（{n}）', // written out: 'ja' sets them so
  numberGap: 'em', hangingIndent: em(2), // turnovers clear the number
  fontSize: em(0.8), lineHeight: em(1.5),
  separator: { width: 1 / 3, lineWidth: pt(0.5), color: col('plum') },
};
// Ichiyō's sesame marks (傍点) over ぎつちよんちよん: the mark Japan uses, right of the line.
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  emphasisMark: { style: 'sesame', position: 'over' },
};
```

## Ingredients

**Ensenya**

- [Notes dels llibres japonesos](https://postext.dev/ca/docs/japanese-layout.md#notes): Les notes com les componen els llibres japonesos. En el text vertical la crida és （1） amb xifres dretes a la dreta de la línia i les notes s'apleguen al final del capítol (後注); en l'horitzontal van al peu, numerades per pàgina sota un filet d'un terç de la mesura. footnotes.placement: 'spread' posa notes laterals (傍注) al tall davanter de la pàgina esquerra de cada doble pàgina, i footnotes.markerPosition: 'side' col·loca una crida petita entre línies al costat de la paraula (合印).
- [Notes a peu de pàgina](https://postext.dev/ca/docs/document-format.md#notes-a-peu-de-pàgina): Notes citades amb [^id] i compostes al peu de la columna que les cita, o després del capítol, numerades per capítol o per tot el llibre.
- [Marques d'èmfasi japoneses (bōten)](https://postext.dev/ca/docs/japanese-layout.md#marques-dèmfasi-bōten): El japonès emfasitza amb una marca al costat de cada caràcter, mai amb cursiva: *…* o :dots[…] en un document etiquetat ja posen un punt de sèsam ﹅ damunt del text horitzontal i a la dreta del vertical. cjk.emphasisMark en canvia la forma (sèsam, punt, cercle, triangle…), l'omple o la deixa buida i la passa a l'altre costat.

**També fa servir**

- [Text vertical](https://postext.dev/ca/docs/configuration.md#escriptura-vertical)
- [Llibres enquadernats per la dreta](https://postext.dev/ca/docs/configuration.md#enquadernació)
- [Furigana: lectures en kana sobre els kanji](https://postext.dev/ca/docs/japanese-layout.md#furigana)
- [Números drets en text vertical](https://postext.dev/ca/docs/configuration.md#nombres-en-text-vertical)
- [Numerals japonesos i comptadors en kana](https://postext.dev/ca/docs/japanese-layout.md#numerals-i-comptadors)
- [Títols que ocupen línies del text (gyōdori)](https://postext.dev/ca/docs/japanese-layout.md#títols-i-blocs)
- [Retícula de caràcters](https://postext.dev/ca/docs/configuration.md#retícula-de-caràcters)
- [Tall de línies japonès (kinsoku)](https://postext.dev/ca/docs/japanese-layout.md#tall-de-línies-kinsoku)
- [Importar textos d'Aozora Bunko](https://postext.dev/ca/docs/japanese-layout.md#textos-daozora-bunko)
- [Fonts xineses, japoneses i coreanes](https://postext.dev/ca/docs/configuration.md#fonts-xineses-japoneses-i-coreanes)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Tall de línies en xinès](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental)
- [Punts d'èmfasi i línies de nom i de títol](https://postext.dev/ca/docs/document-format.md#marques-xineses-ruby-i-warichu)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Preliminars en romans](https://postext.dev/ca/docs/document-format.md#numbering)
- [Ruby: lectures en pinyin i zhuyin](https://postext.dev/ca/docs/document-format.md#marques-xineses-ruby-i-warichu)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`cjk`](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), `footnotes`, [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

- Shippori Mincho (OFL-1.1), Noto Sans JP (OFL-1.1)

## Elaboració

### 1 · Aplega les notes del plec al tall de la pàgina de l'esquerra

El codi és [la resposta curta](#la-resposta-curta) de més amunt. `footnotes.placement: 'spread'` compon les notes citades a les pàgines 2 i 3 al final del text de la pàgina 3, que en un llibre enquadernat per la dreta és el seu tall, i `numbering: 'spread'` comença en （1） les notes de cada plec ([Notes a peu de pàgina](/ca/docs/configuration#notes-a-peu-de-pàgina)). Una nota citada a la pàgina de la dreta espera a la de l'esquerra; si les notes deixessin a la pàgina de l'esquerra menys d'una línia de text, les que sobren es queden al peu de la pàgina de la dreta, com permet JLReq. Un document `ja` ja compon les crides d'un llibre vertical com `（{n}）` a la dreta de la línia, a 0,7 em i amb les xifres dretes, no ajagudes: les dues línies escrites aquí ho mostren. `numberGap: 'em'` deixa un caràcter sencer de nota després del número i `hangingIndent: em(2)` alinea les línies següents al darrere.

### 2 · Escriu les crides on les posa el japonès

El Markdown escriu `見返り柳[^1]` després de la paraula que explica la nota, i les notes com a paràgrafs `[^1]: …` al final del capítol. La crida segueix la paraula que explica; si tanca una frase, va abans del seu 。 (`…先生[^1]。`), i el motor mai no deixa que un 。 o una crida comencin línia. Les set notes es van escriure per a aquesta recepta: què eren el 見返り柳 i la お齒ぐろ溝, on era 大音寺前, què es venia al 酉の市, en un japonès modern i planer.

### 3 · Conserva les marques de sèsam de l'original

`cjk.emphasisMark` decideix què imprimeixen `*…*` i `:dots[…]` en aquest llibre: punts de sèsam, a la dreta d'una línia vertical. Un document `ja` ja els fa servir; escrits, activen el kenten a la llista de funcions. El text d'Aozora marca ぎつちよんちよん amb 傍点, i `aozora.py` el va escriure `:dots[ぎつちよんちよん]{style=sesame}`; els llibres xinesos posen els punts sota la línia ([Marques, ruby i warichu](/ca/docs/configuration#marques-ruby-i-warichu)).

### 4 · Comença en un plec

```js
// script.js, línies 52–71
// Page-anchored design on a vertical page: x runs down the page from its top edge, y across
// it leftwards from its right edge. The title stands on the page's axis, the author lower.
const PT = 25.4 / 72; // mm in a point
const page = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const story = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: lines(LINES), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
      placement: page(42, 127 / 2 - (TITLE * PT) / 2) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('plum'),
      placement: { ...page(42, 127 / 2 + (TITLE * PT) / 2 + 5), size: { width: mm(24) } } },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(12),
      letterSpacing: pt(4), color: col('ink'),
      placement: page(118, 127 / 2 + (TITLE * PT) / 2 + 3) },
  ] } } };
const section = { level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11),
  lineSpan: 3, indent: em(7) }; // 3行取り, seven characters down, as the Aozora text marks it
```

La primera frase del capítol cita quatre notes a les seves tres primeres línies. Sota un títol a la pàgina 1 deixarien lloc per a tres línies de text: la pàgina de l'esquerra només porta les seves pròpies notes. Una portada sola a la pàgina 1 porta el capítol a la pàgina 2, una pàgina de la dreta, i el primer plec aplega el text i totes les seves notes. La portada és un títol de primer nivell el disseny del qual ocupa la pàgina, ancorat al format perquè el títol quedi a l'eix de la pàgina.

### 5 · Posa el hashira i els folis al tall

```js
// script.js, línies 75–89
const foreEdge = (id, content, parity, edge, y, pages) => ({
  kind: 'text', id, content, parity, pages, writingMode: 'vertical-rl',
  fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'),
  overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('hashira', '{title}', 'odd', 'top', 2, 'body'),
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -2, 'body'),
] };
// The title page carries the colophon across the foot, in the edition's language.
const footer = { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}',
  pages: 'opener', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-6) },
    size: { width: mm(100) } } }] };
```

El títol baixa pel marge exterior de les pàgines de l'esquerra, per fora de les notes, i els folis en kanji van al peu de totes les pàgines menys la portada.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/vertical-notes-and-sidenotes

### script.js

```js
// ═══ Postext Cookbook · Nº 121 · Notes beside a vertical Japanese text, across the spread ══
// https://postext.dev/en/cookbook/vertical-notes-and-sidenotes
// Code: MIT · Text: 樋口一葉『たけくらべ』, Aozora Bunko 389 (PD); notes CC BY 4.0 · Pictures: none
// Fonts: Shippori Mincho, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.16.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the colophon; the story is Japanese in both
const RECIPE = 'vertical-notes-and-sidenotes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, a muted plum for the note numbers' rule, cream paper
const palette = {
  ink: '#211d1b', // the text and the notes
  plum: '#6b3a55', // 紫: the rule over the notes, the engine's defaults
  muted: '#6b645d', // the hashira and the folios
  paper: '#fbf7ef',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'defaults', value: { hex: palette.plum, model: 'hex' } },
];
// #endregion
const [MINCHO, GOTHIC] = ['Shippori Mincho', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [9.25, 16.25, 42, 17]; // pt, pt: 四六判 42字 × 17行
const lines = (n) => pt(n * LEAD);

// #region answer: 傍注, the notes of both pages of a spread at the fore-edge of the left one
// placement 'spread' gathers the notes cited on the two pages of a spread at the end of the
// left-hand page's text, the fore-edge in a book bound on the right, under a rule a third of
// the line long, and numbers them per spread. The marker （1） stands small at the right of
// the line, its digits upright, and the note opens with its number and a full note-em.
const footnotes = {
  placement: 'spread', // 傍注; 'chapterEnd' sets 後注 after the chapter instead
  numbering: 'spread',
  markerPosition: 'right', markerTemplate: '（{n}）', // written out: 'ja' sets them so
  numberGap: 'em', hangingIndent: em(2), // turnovers clear the number
  fontSize: em(0.8), lineHeight: em(1.5),
  separator: { width: 1 / 3, lineWidth: pt(0.5), color: col('plum') },
};
// Ichiyō's sesame marks (傍点) over ぎつちよんちよん: the mark Japan uses, right of the line.
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  emphasisMark: { style: 'sesame', position: 'over' },
};
// #endregion

// #region opener: a title page (扉) on its own, so the story opens on a spread
// Page-anchored design on a vertical page: x runs down the page from its top edge, y across
// it leftwards from its right edge. The title stands on the page's axis, the author lower.
const PT = 25.4 / 72; // mm in a point
const page = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const story = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: lines(LINES), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
      placement: page(42, 127 / 2 - (TITLE * PT) / 2) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('plum'),
      placement: { ...page(42, 127 / 2 + (TITLE * PT) / 2 + 5), size: { width: mm(24) } } },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(12),
      letterSpacing: pt(4), color: col('ink'),
      placement: page(118, 127 / 2 + (TITLE * PT) / 2 + 3) },
  ] } } };
const section = { level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11),
  lineSpan: 3, indent: em(7) }; // 3行取り, seven characters down, as the Aozora text marks it
// #endregion

// #region furniture: the hashira and kanji folios down the fore-edge
const foreEdge = (id, content, parity, edge, y, pages) => ({
  kind: 'text', id, content, parity, pages, writingMode: 'vertical-rl',
  fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'),
  overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('hashira', '{title}', 'odd', 'top', 2, 'body'),
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -2, 'body'),
] };
// The title page carries the colophon across the foot, in the edition's language.
const footer = { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}',
  pages: 'opener', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-6) },
    size: { width: mm(100) } } }] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(127), height: mm(188), dpi: 150, // 四六判
    backgroundColor: col('paper'),
    margins: { top: mm(27), bottom: mm(21), left: mm(13), right: mm(16), mirror: true },
    pageNumbering: { format: 'japanese-informal' },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk,
  footnotes,
  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: MINCHO, color: col('ink'), levels: [story, section] },
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "たけくらべ"
author: "樋口一葉"
---

# たけくらべ {colophon="Higuchi Ichiyō, たけくらべ (Juegos de niños, 1895–96), capítulo primero; las notas son de esta receta. Texto: Aozora Bunko 389, dominio público. Compuesto en Shippori Mincho y Noto Sans JP (SIL OFL)."}

## 一

廻れば大門の見返り柳[^1]いと長けれど、お齒ぐろ溝[^2]に{燈火|ともしび}うつる三階[^3]の騷ぎも手に取る如く、明けくれなしの車の{行來|ゆきゝ}にはかり知られぬ全盛をうらなひて、{大音寺前|だいおんじまへ}[^4]と名は佛くさけれど、さりとは陽氣の町と住みたる人の申き、{三嶋神社|みしまさま}の角をまがりてより是れぞと見ゆる{大厦|いへ}もなく、かたぶく軒端の十軒長屋二十軒長や、商ひはかつふつ利かぬ處とて半さしたる雨戸の外に、あやしき{形|なり}に紙を切りなして、胡粉ぬりくり彩色のある{田樂|でんがく}みるやう、裏にはりたる串のさまもをかし、一軒ならず二軒ならず、朝日に干して夕日に仕舞ふ手當こと〴〵しく、一家内これにかゝりて夫れは何ぞと問ふに、知らずや霜月{酉|とり}の日[^5]例の神社に欲深樣のかつぎ給ふ是れぞ熊手の下ごしらへといふ、正月門松とりすつるよりかゝりて、一年うち通しの夫れは誠の商賣人、片手わざにも夏より手足を色どりて、{新年着|はるぎ}の支度もこれをば當てぞかし、南無や大鳥大明神、買ふ人にさへ大福をあたへ給へば製造もとの我等萬倍の利益をと人ごとに言ふめれど、さりとは思ひのほかなるもの、此あたりに大長者のうわさも聞かざりき、住む人の多くは{廓者|くるわもの}にて良人は小格子[^6]の何とやら、下足札そろへてがらんがらんの音もいそがしや夕暮より羽織引かけて立出れば、うしろに切火打かくる女房の顏もこれが見納めか十人ぎりの側杖無理{情死|しんぢう}のしそこね、恨みはかゝる身のはて危ふく、すはと言はゞ命がけの勤めに{遊山|ゆさん}らしく見ゆるもをかし、娘は{大籬|おほまがき}の{下新造|したしんぞ}とやら、七軒の何屋が客廻しとやら、{提燈|かんばん}さげてちよこちよこ走りの修業、卒業して何にかなる、とかくは檜舞臺と見たつるもをかしからずや、垢ぬけのせし三十あまりの年増、小ざつぱりとせし唐棧ぞろひに紺足袋はきて、雪駄ちやら〳〵忙がしげに横抱きの小包はとはでもしるし、茶屋が棧橋とんと沙汰して、廻り遠や此處からあげまする、誂へ物の仕事やさんと此あたりには言ふぞかし、一體の風俗よそと變りて、{女子|おなご}の後帶きちんとせし人少なく、がらを好みて巾廣の卷帶、年増はまだよし、十五六の小癪なるが{酸漿|ほゝづき}ふくんで此{姿|なり}はと目をふさぐ人もあるべし、所がら是非もなや、昨日河岸店に何紫の源氏名耳に殘れど、けふは地廻りの吉と手馴れぬ燒鳥の夜店を出して、身代たゝき骨になれば再び古巣への{内儀|かみさま}姿、どこやら素人よりは見よげに覺えて、これに染まらぬ子供もなし、秋は九月{仁和賀|にわか}[^7]の頃の大路を見給へ、さりとは宜くも學びし{露八|ろはち}が物眞似、{榮喜|えいき}が{處|しよ}作、孟子の母やおどろかん上達の速やかさ、うまいと褒められて今宵も一廻りと生意氣は七つ八つよりつのりて、やがては肩に置手ぬぐひ、鼻歌のそゝり節、十五の少年がませかた恐ろし、學校の唱歌にも:dots[ぎつちよんちよん]{style=sesame}と拍子を取りて、運動會に木やり音頭もなしかねまじき風情、さらでも教育はむづかしきに教師の苦心さこそと思はるゝ{入谷|いりや}ぢかくに育英舍とて、私立なれども生徒の數は千人近く、狹き校舍に目白押の窮屈さも教師が人望いよ〳〵あらはれて、唯學校と一ト口にて此あたりには呑込みのつくほど成るがあり、通ふ子供の數々に或は火消鳶人足、おとつさんは{刎橋|はねばし}の番屋に居るよと習はずして知る其道のかしこさ、梯子のりのまねびにアレ忍びがへしを折りましたと訴へのつべこべ、三百といふ代言の子もあるべし、お前の父さんは馬だねへと言はれて、名のりや{愁|つ}らき子心にも顏あからめるしほらしさ、出入りの{貸座敷|いへ}の祕藏息子寮住居に華族さまを氣取りて、ふさ付き帽子面もちゆたかに洋服かる〴〵と花々敷を、坊ちやん坊ちやんとて此子の追從するもをかし、多くの中に{龍華|りうげ}寺の{信如|しんによ}とて、{千筋|ちすぢ}となづる黒髮も今いく{歳|とせ}のさかりにか、やがては墨染にかへぬべき袖の色、{發心|ほつしん}は腹からか、坊は親ゆづりの勉強ものあり、性來をとなしきを友達いぶせく思ひて、さま〴〵の惡戲をしかけ、猫の死骸を繩にくゝりてお役目なれば{引導|いんだう}をたのみますと投げつけし事も有りしが、それは昔、今は校内一の人とて假にも{侮|あなど}りての處業はなかりき、歳は十五、{並背|なみぜい}にていが栗の{頭髮|つむり}も思ひなしか俗とは變りて、{藤本信如|ふぢもとのぶゆき}と{訓|よみ}にてすませど、何處やら{釋|しやく}といひたげの素振なり。

[^1]: 吉原遊廓の入口、大門へ下る衣紋坂の上の日本堤に立っていた柳。帰りの客がここで名残を惜しんで振り返ったという。

[^2]: 遊廓のまわりをめぐる堀。遊女が逃げ出さないように掘られたもので、水が黒く濁っていたのでこう呼ばれた。

[^3]: 廓の中に建ち並ぶ三階建ての大きな妓楼。

[^4]: 吉原の裏手、下谷龍泉寺町のあたりの通称。大音寺の門前にあたる。

[^5]: 十一月の酉の日に鷲（おおとり）神社で開かれる酉の市。縁起物の熊手が売られる。

[^6]: 格の低い、小さな遊女屋。

[^7]: 吉原で秋に催された俄（にわか）。芸者や幇間が茶番や踊りを見せて廓の中を練り歩いた。
`; // content.<lang>.md: the same Japanese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Shippori Mincho': ['400', '700'], // the text and the notes; the title and 一
  'Noto Sans JP': ['400'], // the hashira, the folios and the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const title = markdown.match(/^title: "(.*)"$/m)[1];
const author = markdown.match(/^author: "(.*)"$/m)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
// The notes' （1） and their upright digits come from the text face too.
await loadCjkFonts({ [MINCHO]: ['400'] }, `${markdown}（）0123456789`, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, `${title}${author}一`);
await loadCjkFonts({ [GOTHIC]: ['400'] },
  `${title}${markdown.match(/colophon="([^"]*)"/)[1]}一二三四五六七八九十`, { vertical: true });
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'Notes beside a vertical Japanese text, across the spread',
  es: 'Notas junto a un texto japonés vertical, en la doble página' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Aplega les notes al final del capítol

Sense `placement`, un llibre `ja` vertical compon les notes com a 後注 (kōchū): després del text del capítol, sota el mateix filet i numerades al llarg del capítol. Aquí ocupen la meitat esquerra de la pàgina 4.

```diff
-  placement: 'spread', // 傍注; 'chapterEnd' sets 後注 after the chapter instead
-  numbering: 'spread',
+  placement: 'chapterEnd', // 後注, the default for a vertical ja book
+  numbering: 'chapter',
```

### Posa la crida al costat de la paraula

`markerPosition: 'side'` compon una crida més petita al buit del costat de la línia, com un ruby entre línies, de manera que no ocupa lloc a la línia.

```diff
-  markerPosition: 'right', markerTemplate: '（{n}）', // written out: 'ja' sets them so
+  markerPosition: 'side', markerTemplate: '{n}',
```

## Errors freqüents

- **Etiqueta un text japonès 'ja', mai zh-Hans ni LANG.** Les edicions d'una recepta són en i es, però una mostra japonesa és japonesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, i una etiqueta xinesa la compondria amb les regles xineses (puntuació Kaiming, kana petit lliure de començar línia, 图 en lloc de 図, formes xineses dels caràcters al PDF). Escriu 'ja': tria la regió del Japó (tall i puntuació de la JLReq, punts de sèsam, repartiment dels furigana, etiquetes 図 i 表) i desactiva la partició de mots. El lint falla un text amb kana sota una etiqueta zh o ko.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Les fonts xineses es carreguen per fragments, amb el bloc cjk.** Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text.
- **Un llibre enquadernat per la dreta mostra els seus plecs amb showBook.** En un llibre enquadernat per la dreta (text àrab, hebreu o persa, xinès vertical, o page.binding 'right') la pàgina 1 continua sent senar, però queda a l'esquerra del llom, i els plecs es llegeixen [3 | 2]. showPages compon tots els llibres com si s'enquadernessin per l'esquerra; showBook, del bloc book (el bloc cjk porta la mateixa funció), llegeix doc.binding i reflecteix els plecs. capture.hero continua anomenant un plec en ordre de lectura, [parella, senar]: [2, 3].

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: 樋口一葉「たけくらべ」 (Child’s Play, 1895–96), chapter one; Aozora Bunko card 389, from 「日本現代文學全集10　樋口一葉集」 (Kōdansha, 1962), input 青空文庫, proofreading 米田進 and 小林繁雄, converted with aozora.py; the seven notes were written for this recipe (CC BY 4.0): Higuchi Ichiyō; Aozora Bunko volunteers ([font](https://www.aozora.gr.jp/cards/000064/card389.html)), domini públic
- Tipus de lletra: Shippori Mincho (OFL-1.1), Noto Sans JP (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 071 · Notes al peu de la columna que les cita](https://postext.dev/ca/cookbook/novel-footnotes-column-foot.md): Les crides [^id] passen a notes numerades al peu de la columna que les cita, amb interruptors per a una columna, notes finals i numeració contínua. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 091 · Xinès vertical amb citacions en notes dins de la línia](https://postext.dev/ca/cookbook/vertical-book-with-jiazhu-citations.md): Un capítol vertical en caràcters tradicionals, enquadernat per la dreta, amb les citacions de nota GB/T 7714 compostes com a 夾注 dins de la columna. · Nivell 3 (Avançat) · Qualsevol gènere
- [Núm. 020 · Notes finals en pàgina pròpia, a dues columnes](https://postext.dev/ca/cookbook/endnotes-instead-of-footnotes.md): Un preprocessador breu numera les notes [^1] i les reuneix sota un títol Notes, l'estil del qual les compon en pàgina pròpia, a dues columnes. · Nivell 2 (Intermedi) · Articles i treballs acadèmics
