# Kanbun com suas marcas de leitura e a leitura japonesa

> Três ditos dos Analectos e uma quadra Tang como num livro escolar japonês: marcas de retorno e okurigana em vermelhão, e a leitura em kana sob cada trecho.

- Versão HTML: https://postext.dev/pt/cookbook/kanbun-kundoku
- Receita Nº 122 · Tipo e texto · Nível 2 (Intermediário) · Saídas: Canvas, PDF
- Gêneros: Livros didáticos
- Requer postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · testada com 1.19.1, postext-pdf 1.19.1 em 2026-10-06
- Páginas: [一](https://postext.dev/cookbook/kanbun-kundoku/en/p01.webp?v=596c5a2a), [二](https://postext.dev/cookbook/kanbun-kundoku/en/p02.webp?v=596c5a2a), [三](https://postext.dev/cookbook/kanbun-kundoku/en/p03.webp?v=596c5a2a)
- PDF: https://postext.dev/cookbook/kanbun-kundoku/en/kanbun-kundoku.pdf?v=596c5a2a
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=kanbun-kundoku&lang=en (.postext: https://postext.dev/cookbook/kanbun-kundoku/en/kanbun-kundoku.postext)
- Última atualização: 2026-10-05
- Outros idiomas: [en](https://postext.dev/en/cookbook/kanbun-kundoku.md), [es](https://postext.dev/es/cookbook/kanbun-kundoku.md), [ca](https://postext.dev/ca/cookbook/kanbun-kundoku.md), [zh](https://postext.dev/zh/cookbook/kanbun-kundoku.md), [ja](https://postext.dev/ja/cookbook/kanbun-kundoku.md), [ar](https://postext.dev/ar/cookbook/kanbun-kundoku.md)

## Em poucas palavras

O chinês clássico como os leitores japoneses o aprendem: pequenas marcas vermelhas ao lado dos caracteres dizem em que ordem lê-los e que terminações japonesas acrescentar, e a leitura japonesa completa vem depois de cada trecho.

## O que você vai compor

Três páginas de um livro escolar japonês de 漢文 (kanbun), o chinês clássico lido como japonês, numa página A5 encadernada pela direita: três ditos dos *Analectos* e a quadra *Amanhecer de primavera*, de Meng Haoran. Cada trecho é composto em Zen Old Mincho de 15 pt na ordem chinesa, com os seus 訓点 (kunten) ao lado dos caracteres, em vermelhão e com metade do tamanho deles: as marcas de retorno (返り点), embaixo à esquerda de um caractere, dizem ao leitor que siga adiante e volte a ele, e o okurigana (送り仮名) à direita acrescenta as terminações japonesas. Sob cada trecho, o seu 書き下し文 (yomikudashi), o mesmo texto escrito como japonês, vem em Klee One, uma letra escolar, com furigana onde a leitura é difícil. Os clássicos chineses com os seus próprios auxílios de leitura estão na [Nº 080](https://postext.dev/pt/cookbook/zhuyin-vertical-reader.md).

**Esta receita responde a:**

- Como faço para imprimir kanbun com as marcas de retorno e o okurigana, e a leitura japonesa depois?
- Como coloco furigana sobre os kanji, uma leitura por caractere ou uma para a palavra inteira?

## A resposta curta

```js
// script.js, linhas 32–52
// :kunten[習]{kaeri="レ" okuri="フ"} sets the return mark small at the character's lower
// left and the okurigana at its lower right, down the line; the text keeps its Chinese
// order, which the reader follows by the marks. cjk.kunten sizes and colours them: half
// the character, in vermilion. The Japanese reading under each passage is plain kana and
// kanji with furigana: {君子|くん|し} gives each character its reading (jukugo ruby).
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
};
const paragraphStyles = [
  // The text: 15 pt on a pitch of two grid lines, room for the okurigana beside it. A
  // quatrain takes two lines, a couplet to the line with a space between its verses.
  { id: 'kanbun', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    firstLineIndent: em(0), textAlign: 'justify' },
  { id: 'shi', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  // The reading (書き下し文), two characters down in a school-book hand.
  { id: 'kudashi', indent: em(2), firstLineIndent: em(0), marginTop: pt(LEAD) },
  { id: 'kudashi-shi', indent: em(4), firstLineIndent: em(0), marginTop: pt(0),
    marginBottom: pt(0) },
];
```

## Ingredientes

**Ensina**

- [Marcas de leitura do kanbun (kunten)](https://postext.dev/pt/docs/japanese-layout.md#kanbun): O chinês clássico lido em japonês (漢文訓読) leva pequenas marcas ao lado dos caracteres: :kunten[字]{kaeri=レ okuri=ヲ} compõe a marca de retorno (返り点: レ, 一 二 三, 上 下) em corpo pequeno embaixo, à esquerda do caractere, e o okurigana embaixo, à direita, no texto vertical. O texto mantém a ordem chinesa: o leitor segue as marcas.
- [Furigana: leituras em kana sobre os kanji](https://postext.dev/pt/docs/japanese-layout.md#furigana): Leituras em kana compostas sobre os kanji do texto horizontal e à direita dos do vertical. {漢字|かん|じ} dá a cada caractere sua própria leitura e permite quebrar a linha entre eles (rubi jukugo), {夕方|ゆうがた} põe uma leitura para a palavra inteira (rubi de grupo), e :ruby[…]{mode align} escolhe um ou outro modo. Em um documento marcado como ja, as leituras são distribuídas na proporção 1:2:1 (JIS), podem avançar um caractere de rubi sobre o kana vizinho, mas nunca sobre um kanji, e conservam seus kana pequenos.
- [Texto vertical](https://postext.dev/pt/docs/configuration.md#escrita-vertical): Chinês e japonês compostos de cima para baixo, em linhas lidas a partir da direita (layout.writingMode 'vertical-rl'), com as colunas como faixas, figuras e tabelas em pé e a pontuação nas suas formas verticais.

**Também usa**

- [Livros encadernados pela direita](https://postext.dev/pt/docs/configuration.md#encadernação)
- [Rubi: leituras em pinyin e zhuyin](https://postext.dev/pt/docs/document-format.md#marcas-chinesas-rubi-e-warichu)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Títulos sobre linhas do texto (gyōdori)](https://postext.dev/pt/docs/japanese-layout.md#títulos-e-blocos)
- [Kana, kanji e rōmaji](https://postext.dev/pt/docs/japanese-layout.md#kanji-kana-e-rōmaji)
- [Quebra de linha em japonês (kinsoku)](https://postext.dev/pt/docs/japanese-layout.md#quebra-de-linha-kinsoku)
- [Numerais japoneses e contadores em kana](https://postext.dev/pt/docs/japanese-layout.md#numerais-e-contadores)
- [Grade de caracteres](https://postext.dev/pt/docs/configuration.md#grade-de-caracteres)
- [Fontes chinesas, japonesas e coreanas](https://postext.dev/pt/docs/configuration.md#fontes-chinesas-japonesas-e-coreanas)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Exportação para PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)
- [Quebra de linha em chinês](https://postext.dev/pt/docs/configuration.md#tipografia-do-leste-asiático)
- [Equilíbrio de colunas](https://postext.dev/pt/docs/configuration.md#equilíbrio-de-colunas)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes)
- [Elementos pré-textuais em romanos](https://postext.dev/pt/docs/document-format.md#numbering)
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`cjk`](https://postext.dev/pt/docs/configuration.md#tipografia-do-leste-asiático), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`decompressWoff2`](https://postext.dev/pt/docs/configuration.md#provedor-de-fontes-no-navegador-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap), [`renderToPdf`](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)

**Tipos**

- Zen Old Mincho (OFL-1.1), Klee One (OFL-1.1), Noto Sans JP (OFL-1.1)

## Preparo

### 1 · Marque cada caractere com `:kunten`

O código é [a resposta curta](https://postext.dev/pt/cookbook/kanbun-kundoku.md#a-resposta-curta) logo acima. `:kunten[習]{kaeri="レ" okuri="フ"}` dá a 習 a marca de retorno レ e o okurigana フ; o texto mantém o caractere, então a busca, a cópia e o PDF leem o trecho como foi escrito, e as marcas não ocupam nenhum caractere dele ([Marcas, rubi e warichu](https://postext.dev/pt/docs/configuration.md#marcas-rubi-e-warichu)). Leia 学ビテ而時ニ習フレ之ヲ: a レ em 習 manda o leitor a 之 primeiro e de volta, 之を習ふ; 而 não tem marca e não é lido. No texto vertical, a marca de retorno fica embaixo à esquerda do seu caractere e ocupa meio caractere da linha, e o okurigana fica à direita, começando na metade da altura do caractere, como o JIS X 4051 os compõe. `cjk.kunten` define o tamanho deles, metade do texto, e a cor, o vermelhão em que um leitor os escreve; um trecho tem 15 pt em duas linhas da grade, o que deixa 27 pt ao lado de cada linha para o okurigana.

### 2 · Salte com 一 二 e com 一レ

Os números levam o leitor mais para trás. Em 不二亦タ説バシカラ一乎, o leitor pula 不, que tem 二, lê 亦た説ばしから e, no 一, volta a 不: 亦た説ばしからずや. Em 為政, a marca combinada 一レ em 為 faz as duas coisas: 可二以テ為一レ師ト矣 se lê 以て, 師と (レ), 為る e então de volta a 可 (de 一 a 二), 以て師と為るべし. Os pontos de código de kanbun ㆑ ㆒ ㆓ são lidos como os caracteres que representam, então um texto copiado de outro lugar mantém as suas marcas.

### 3 · Deixe um okurigana longo empurrar o caractere seguinte

Um okurigana mais longo que o seu caractere não corre ao lado do seguinte: em 花落ツルコト知ル多少, os quatro kana de ツルコト ocupam o comprimento de dois caracteres, e 知 desce para começar depois deles, como o JIS X 4051 os compõe (§5.6.4). A linha cresce, e a sua quebra seguinte se desloca com ela.

### 4 · Dê a cada trecho um rótulo de duas linhas

```js
// script.js, linhas 56–71
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const part = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: pt(5 * LEAD), 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: at(2 * BODY, 2 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(8.5),
      letterSpacing: pt(2), color: col('vermilion'), placement: at(2 * BODY + 3 * TITLE + 18,
        2 * LEAD - 8.5 / 2) },
  ] } } };
// 2行取り, the passage's book and number in gothic, two characters down.
const passage = { level: 2, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9.5),
  color: col('muted'), lineSpan: 2, indent: em(2) };
```

Cada livro da antologia abre uma página com o seu título, 論語 ou 唐詩, o primeiro com uma pequena nota em vermelhão sobre o que contém; cada trecho leva um rótulo em gótica com a altura de duas linhas do texto (2行取り), 学而第一 ou 春暁　孟浩然, para que os trechos abaixo dele fiquem na grade. A leitura é um bloco `:::paragraphs{style="kudashi"}` dois caracteres mais abaixo, na fonte e no corpo do texto, com furigana jukugo: `{君子|くん|し}` dá a cada caractere a sua própria leitura, e assim uma linha poderia quebrar entre eles.

### 5 · Carregue cada voz com os seus caracteres

```js
// script.js, linhas 175–188
const FENCE = /:::paragraphs\{style="([\w-]+)"\}\n([\s\S]*?)\n:::/g;
const fence = (style) => [...markdown.matchAll(FENCE)].filter((m) => m[1] === style)
  .map((m) => m[2]).join('');
const kanbun = `${fence('kanbun')}${fence('shi')}`; // the text and its marks' kana
const reading = `${fence('kudashi')}${fence('kudashi-shi')}`;
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, kanbun, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [KYOKASHO]: ['400'] }, reading, { vertical: true });
await loadCjkFonts({ [GOTHIC]: ['400', '700'] },
  `${heads}${colophon}${markdown.match(/kicker="([^"]*)"/)[1]}漢文訓読一二三四五六七八九十`,
  { vertical: true });
```

O texto clássico e a leitura são compostos em fontes diferentes, então cada uma carrega os arquivos dos seus próprios caracteres: Zen Old Mincho para os trechos e as suas marcas, cujos kana ficam dentro dos atributos de `:kunten`; Klee One para as leituras e o seu furigana; Noto Sans JP para os rótulos. A quadra é composta com dois versos por linha e um espaço ideográfico entre eles, um modo comum de imprimi-la, para que o poema e a sua leitura caibam na página sob o título.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: https://github.com/drnachio/postext/tree/main/cookbook/kanbun-kundoku

### script.js

```js
// ═══ Postext Cookbook · Nº 122 · Kanbun with its reading marks, and the Japanese reading ═══
// https://postext.dev/en/cookbook/kanbun-kundoku
// Code: MIT · Text: 論語, 孟浩然「春暁」 (public domain); kunten and readings CC BY 4.0
// Fonts: Zen Old Mincho, Klee One, 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 = 'en'; // @lang: the language of the colophon; the page is Japanese in both
const RECIPE = 'kanbun-kundoku';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, the vermilion of hand-written kunten, a school-book paper
const palette = {
  ink: '#1e1c1a', // the text
  vermilion: '#c0392b', // 朱: the reading marks, as a reader adds them by hand
  muted: '#6a635c', // the headings' labels, the folios
  paper: '#fcfbf7',
};
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.vermilion, model: 'hex' } },
];
// #endregion
const [MINCHO, KYOKASHO, GOTHIC] = ['Zen Old Mincho', 'Klee One', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [10.5, 21, 36, 16]; // pt, pt: 36字 × 16行, a 2 em pitch
const KANBUN = 15; // pt: the classical text, set larger on two lines of the grid

// #region answer: kanbun with 返り点 and 送り仮名, its yomikudashi beside it in furigana
// :kunten[習]{kaeri="レ" okuri="フ"} sets the return mark small at the character's lower
// left and the okurigana at its lower right, down the line; the text keeps its Chinese
// order, which the reader follows by the marks. cjk.kunten sizes and colours them: half
// the character, in vermilion. The Japanese reading under each passage is plain kana and
// kanji with furigana: {君子|くん|し} gives each character its reading (jukugo ruby).
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
};
const paragraphStyles = [
  // The text: 15 pt on a pitch of two grid lines, room for the okurigana beside it. A
  // quatrain takes two lines, a couplet to the line with a space between its verses.
  { id: 'kanbun', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    firstLineIndent: em(0), textAlign: 'justify' },
  { id: 'shi', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  // The reading (書き下し文), two characters down in a school-book hand.
  { id: 'kudashi', indent: em(2), firstLineIndent: em(0), marginTop: pt(LEAD) },
  { id: 'kudashi-shi', indent: em(4), firstLineIndent: em(0), marginTop: pt(0),
    marginBottom: pt(0) },
];
// #endregion

// #region headings: each part opens a page; each passage under a two-line label
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const part = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: pt(5 * LEAD), 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: at(2 * BODY, 2 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(8.5),
      letterSpacing: pt(2), color: col('vermilion'), placement: at(2 * BODY + 3 * TITLE + 18,
        2 * LEAD - 8.5 / 2) },
  ] } } };
// 2行取り, the passage's book and number in gothic, two characters down.
const passage = { level: 2, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9.5),
  color: col('muted'), lineSpan: 2, indent: em(2) };
// #endregion

const foreEdge = (id, content, edge, y, pages) => ({ kind: 'text', id, content, pages,
  writingMode: 'vertical-rl', fontFamily: GOTHIC, fontSize: pt(7.5), letterSpacing: pt(1),
  color: col('muted'), placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } } });
const header = { elements: [
  foreEdge('hashira', '{title}　{chapterTitle}', 'top', 3, 'body'),
  foreEdge('folio', '{pageNumber}', 'bottom', -3, 'all'),
] };
// The first part's opening page carries the colophon across the foot.
const first = { id: 'first', 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(-7) },
    size: { width: mm(118) } } }] } };

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(148), height: mm(210), dpi: 150, // A5
    backgroundColor: col('paper'),
    margins: { top: mm(40), bottom: mm(32), left: mm(13), right: mm(16), mirror: true },
    pageNumbering: { format: 'japanese-informal' },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk,
  bodyText: {
    fontFamily: KYOKASHO, 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: [part, passage],
    balancing: { enabled: false } }, // gotcha: cjk-grid-balancing
  headingStyles: [first],
  paragraphStyles,
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "漢文訓読"
---

# 論語 {style="first" kicker="学而・為政より" colophon="Confucius, 論語 (the Analects), three sayings from the books 學而 and 爲政; Meng Haoran, 春曉 (Spring Dawn). Kunten and reading after the usual school reading, set for this recipe. Set in Zen Old Mincho, Klee One and Noto Sans JP (SIL OFL)."}

## 学而第一

:::paragraphs{style="kanbun"}
子:kunten[曰]{okuri="ハク"}、:kunten[学]{okuri="ビテ"}而:kunten[時]{okuri="ニ"}:kunten[習]{kaeri="レ" okuri="フ"}:kunten[之]{okuri="ヲ"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}:kunten[説]{kaeri="一" okuri="バシカラ"}乎。:kunten[有]{kaeri="レ" okuri="リ"}朋:kunten[自]{kaeri="二" okuri="リ"}遠:kunten[方]{kaeri="一"}:kunten[来]{okuri="タル"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}:kunten[楽]{kaeri="一" okuri="シカラ"}乎。人:kunten[不]{kaeri="レ" okuri="シテ"}:kunten[知]{okuri="ラ"}而:kunten[不]{kaeri="レ"}:kunten[慍]{okuri="ミ"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}君:kunten[子]{kaeri="一" okuri="ナラ"}乎。
:::

:::paragraphs{style="kudashi"}
子{曰|い}はく、{学|まな}びて時に{之|これ}を習ふ、{亦|ま}た{説|よろこ}ばしからずや。{朋|とも}有り{遠方|えん|ぽう}より来たる、{亦|ま}た楽しからずや。人知らずして{慍|うら}みず、{亦|ま}た{君子|くん|し}ならずや。
:::

## 学而第三

:::paragraphs{style="kanbun"}
子:kunten[曰]{okuri="ハク"}、巧言令色、:kunten[鮮]{okuri="ナシ"}矣仁。
:::

:::paragraphs{style="kudashi"}
子{曰|い}はく、{巧言令色|こう|げん|れい|しょく}、{鮮|すく}なし{仁|じん}。
:::

## 為政第十一

:::paragraphs{style="kanbun"}
子:kunten[曰]{okuri="ハク"}、:kunten[温]{kaeri="レ" okuri="メテ"}:kunten[故]{okuri="キヲ"}而:kunten[知]{kaeri="レ" okuri="レバ"}:kunten[新]{okuri="シキヲ"}、:kunten[可]{kaeri="二" okuri="シ"}:kunten[以]{okuri="テ"}:kunten[為]{kaeri="一レ" okuri="ル"}:kunten[師]{okuri="ト"}矣。
:::

:::paragraphs{style="kudashi"}
子{曰|い}はく、{故|ふる}きを{温|あたた}めて新しきを知れば、{以|もっ}て師と{為|な}るべし。
:::

# 唐詩

## 春暁　孟浩然

:::paragraphs{style="shi"}
春眠:kunten[不]{kaeri="レ"}:kunten[覚]{kaeri="レ" okuri="エ"}:kunten[暁]{okuri="ヲ"}　処処:kunten[聞]{kaeri="二" okuri="ク"}啼:kunten[鳥]{kaeri="一" okuri="ヲ"}

夜来風:kunten[雨]{okuri="ノ"}声　花:kunten[落]{okuri="ツルコト"}:kunten[知]{okuri="ル"}多少
:::

:::paragraphs{style="kudashi-shi"}
{春眠|しゅん|みん}{暁|あかつき}を{覚|おぼ}えず　{処処|しょ|しょ}{啼鳥|てい|ちょう}を聞く

{夜来|や|らい}{風雨|ふう|う}の声　花落つること知る{多少|た|しょう}
:::
`; // content.<lang>.md: the same Japanese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Zen Old Mincho': ['400', '700'], // the classical text and its marks; the part titles
  'Klee One': ['400'], // the reading, in a school-book hand
  'Noto Sans JP': ['400', '700'], // labels, the hashira and folios, the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each face loads the files that hold the characters it sets
const FENCE = /:::paragraphs\{style="([\w-]+)"\}\n([\s\S]*?)\n:::/g;
const fence = (style) => [...markdown.matchAll(FENCE)].filter((m) => m[1] === style)
  .map((m) => m[2]).join('');
const kanbun = `${fence('kanbun')}${fence('shi')}`; // the text and its marks' kana
const reading = `${fence('kudashi')}${fence('kudashi-shi')}`;
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, kanbun, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [KYOKASHO]: ['400'] }, reading, { vertical: true });
await loadCjkFonts({ [GOTHIC]: ['400', '700'] },
  `${heads}${colophon}${markdown.match(/kicker="([^"]*)"/)[1]}漢文訓読一二三四五六七八九十`,
  { vertical: true });
// #endregion
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'Kanbun with its reading marks, and the Japanese reading',
  es: 'Kanbun con sus marcas de lectura y la lectura japonesa' }) });
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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** The family's Fontsource metadata (weights, styles, subsets), 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
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. */
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 v2 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType: the nearest weight the
 *  family ships, upright if it has no italic; latin, then what the face's
 *  letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  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 text = String.fromCodePoint(...(request?.codePoints ?? []));
  const more = kitSubsetsFor(text, meta);
  const files = await Promise.all(['latin', ...more].map(async (subset) => {
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Imprima as marcas em preto

Um livro impresso compõe os kunten na tinta do texto; tire a cor.

```diff
-  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
+  kunten: { fontSize: em(0.5), placement: 'inline' },
```

### Ponha as marcas entre as linhas

`placement: 'interlinear'` tira as marcas de retorno da linha e as leva para o espaço ao lado da metade inferior do caractere, e o trecho mantém o espaçamento do texto sem marcas (白文). O JIS as põe na linha, que é o padrão.

```diff
-  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
+  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'interlinear' },
```

## Erros comuns

- **Marque um texto japonês como 'ja', nunca com zh-Hans nem LANG.** As edições de uma receita são en e es, mas uma amostra japonesa é japonesa nas duas: `locale: LANG` a marcaria como inglês ou espanhol, e uma marcação chinesa a comporia pelas regras chinesas (pontuação Kaiming, kana pequeno livre para começar linha, 图 no lugar de 図, formas chinesas dos glifos no PDF). Escreva 'ja': isso escolhe a região do Japão (quebra de linha e pontuação da JLReq, pontos de ênfase em gergelim, espaçamento dos furigana, rótulos 図 e 表) e desativa a hifenização. O lint reprova um texto com kana sob uma marcação zh ou ko.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **Fontes chinesas são carregadas em fatias, pelo bloco cjk.** O Fontsource serve uma família chinesa, japonesa ou coreana em cerca de cem arquivos por peso, cada um com um intervalo de caracteres. loadFonts baixa só o arquivo latin, então na tela os caracteres chineses vêm de uma fonte do sistema e são medidos errado, e o fontsourceProvider entrega ao PDF esse arquivo latin, que os imprime como caixas vazias. Inclua o bloco cjk do kit, chame loadCjkFonts(FONTS, markdown) depois de loadFonts (uma vez por voz, com o texto que ela compõe, quando o livro usa várias fontes CJK) e passe a renderToPdf fontProvider: cjkPdfProvider: os dois pegam os arquivos que contêm os caracteres do texto.
- **Um livro encadernado pela direita mostra as páginas duplas com showBook.** Em um livro encadernado pela direita (texto árabe, hebraico ou persa, chinês vertical, ou page.binding 'right') a página 1 continua sendo a ímpar, mas fica à esquerda da lombada, e os pares se leem [3 | 2]. showPages dispõe todos os livros como se fossem encadernados pela esquerda; showBook, do bloco book (o bloco cjk traz a mesma função), lê doc.binding e espelha os pares. capture.hero continua nomeando uma página dupla em ordem de leitura, [par, ímpar]: [2, 3].
- **Em uma grade de caracteres, desative o balanceamento de colunas.** O balanceamento de colunas preenche uma página que termina curta (como quando um título mantido junto ao seu texto deixa linhas em branco no pé) acrescentando linhas da grade acima dos títulos e compondo um parágrafo com uma linha mais frouxa. Uma linha chinesa mais frouxa espaça os seus caracteres (0,13 eme em uma página GB/T 9704, muito acima de balancing.maxTracking), então os caracteres saem das colunas da grade e os títulos saem das suas linhas. Uma página contada em células deve terminar curta: defina headings.balancing: { enabled: false }.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: 論語 (the Analects of Confucius), 學而 1 and 3, 爲政 11, and 孟浩然「春曉」 (Spring Dawn), in shinjitai; the kunten (返り点, 送り仮名) and the yomikudashi follow the usual Japanese school reading and were set for this recipe (CC BY 4.0): Confucius and his disciples; Meng Haoran; Ignacio Ferro (kunten and reading), domínio público
- Tipos: Zen Old Mincho (OFL-1.1), Klee One (OFL-1.1), Noto Sans JP (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 083 · Poemas Tang diante da sua versão inglesa](https://postext.dev/pt/cookbook/tang-poems-facing-english.md): Cada poema Tang abre uma página ímpar tingida, e o inglês de Giles fica na par em frente: um estilo de título quebra para página par, o outro para ímpar. · Nível 2 (Intermediário) · Poesia
- [Nº 080 · Um livro de leitura vertical com zhuyin à direita](https://postext.dev/pt/cookbook/zhuyin-vertical-reader.md): Um livro escolar de Taiwan composto na vertical: {守株|ㄕㄡˇ|ㄓㄨ} põe uma coluna de zhuyin à direita de cada caractere, com figuras e notas num andar de cima. · Nível 3 (Avançado) · Livros didáticos
- [Nº 078 · Comentário em tinta vermelha na linha e na margem superior](https://postext.dev/pt/cookbook/red-ink-commentary.md): Uma página chinesa comentada, na vertical: os comentários laterais do manuscrito em duas fileiras vermelhas dentro da linha, e os da margem, sobre o texto. · Nível 3 (Avançado) · Ficção, teatro e prosa literária
