# Un livre d’images en hiragana, espacé par groupes

> Un livre d’images vertical en hiragana, une espace entre les groupes (wakachigaki) : keep-all garde chaque groupe entier et ne coupe qu’aux espaces.

- Version HTML: https://postext.dev/fr/cookbook/hiragana-picture-book
- Recette Nº 127 · Caractères et texte · Niveau 2 (Intermédiaire) · Sorties: Canvas, PDF
- Genres: Fiction, théâtre et prose littéraire, Livres de photographie
- Nécessite postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · testée avec 1.31.0, postext-pdf 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/hiragana-picture-book/en/p01.webp?v=1f767d0c), [2](https://postext.dev/cookbook/hiragana-picture-book/en/p02.webp?v=1f767d0c), [3](https://postext.dev/cookbook/hiragana-picture-book/en/p03.webp?v=1f767d0c), [4](https://postext.dev/cookbook/hiragana-picture-book/en/p04.webp?v=1f767d0c), [5](https://postext.dev/cookbook/hiragana-picture-book/en/p05.webp?v=1f767d0c), [6](https://postext.dev/cookbook/hiragana-picture-book/en/p06.webp?v=1f767d0c), [7](https://postext.dev/cookbook/hiragana-picture-book/en/p07.webp?v=1f767d0c), [8](https://postext.dev/cookbook/hiragana-picture-book/en/p08.webp?v=1f767d0c), [9](https://postext.dev/cookbook/hiragana-picture-book/en/p09.webp?v=1f767d0c), [10](https://postext.dev/cookbook/hiragana-picture-book/en/p10.webp?v=1f767d0c)
- PDF: https://postext.dev/cookbook/hiragana-picture-book/en/hiragana-picture-book.pdf?v=1f767d0c
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=hiragana-picture-book&lang=en (.postext: https://postext.dev/cookbook/hiragana-picture-book/en/hiragana-picture-book.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/hiragana-picture-book.md), [es](https://postext.dev/es/cookbook/hiragana-picture-book.md), [ca](https://postext.dev/ca/cookbook/hiragana-picture-book.md), [pt](https://postext.dev/pt/cookbook/hiragana-picture-book.md), [zh](https://postext.dev/zh/cookbook/hiragana-picture-book.md), [ja](https://postext.dev/ja/cookbook/hiragana-picture-book.md), [ar](https://postext.dev/ar/cookbook/hiragana-picture-book.md)

## En bref

Un livre d’images japonais pour les tout-petits, entièrement en hiragana, avec une espace après chaque groupe de mots et des lignes qui n’en coupent jamais un, sous une image à chaque page.

## Ce que vous allez composer

Un livre d’images de dix pages, かみの ふね (*Le bateau de papier*), écrit pour la recette et illustré de dix peintures : après la pluie, une fillette plie un bateau de papier rouge, et le bateau descend la rivière, passe devant une grenouille, un héron et la lune, jusqu’à la mer. Il est composé comme les livres japonais pour les plus jeunes lecteurs : uniquement en hiragana, en 20 pt de Klee One, une écriture dessinée sur le modèle des manuels scolaires, avec une espace entre les groupes de mots, 分かち書き (wakachigaki), pour que l’enfant lise un groupe à la fois. Les pages sont à l’italienne, 260 × 200 mm, verticales et reliées à droite : l’image occupe le haut de la page, de courtes lignes de huit caractères courent dessous, et le bateau avance vers la gauche à mesure que l’on tourne les pages.

**Cette recette répond à:**

- Comment espacer un livre d’images japonais par groupes (wakachigaki) et ne couper ses lignes qu’aux espaces ?
- Pourquoi les petits kana, ー et les crochets fermants ne peuvent-ils pas commencer une ligne en japonais, et comment choisir la sévérité de la règle ?
- Quelles polices composent le japonais, avec les kanji sous leurs formes japonaises, à l’écran et dans le PDF ?

## La réponse courte

```js
// script.js, lignes 40–50
// The story is typed with an ordinary space (U+0020) after each phrase (文節). Japanese
// text may break between any two kana, so the space alone does not keep a phrase whole:
// keep-all drops the breaks between two letters and keeps those at a space, after 、。」
// and before 「. Kinsoku still applies.
const phrases = { wordBreak: 'keep-all' }; // cjk.wordBreak
const bodyText = {
  fontFamily: HAND, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // Ragged: a justified line would stretch its spaces and its kana to fill the tier.
  textAlign: 'left', firstLineIndent: em(0),
};
```

## Ingrédients

**Enseigne**

- [Kana, kanji et rōmaji](https://postext.dev/fr/docs/japanese-layout.md#kanji-kana-et-rōmaji): Le japonais mêle quatre écritures sur une même ligne : kanji, hiragana, katakana et lettres et chiffres latins (rōmaji). Un document balisé ja les compose sans espace entre les mots japonais et avec un quart de cadratin entre japonais et latin, les Ａ１ pleine chasse droits dans le texte vertical, et les kanji sous leurs formes japonaises (le locl de la police) quand le caractère est japonais, comme Noto Serif JP ou Shippori Mincho ; les macrons du rōmaji (ō ū) demandent un caractère qui les possède.
- [Texte vertical](https://postext.dev/fr/docs/configuration-page-layout.md#écriture-verticale): Le chinois et le japonais composés de haut en bas en lignes lues depuis la droite (layout.writingMode 'vertical-rl'), avec les colonnes en étages, les figures et les tableaux droits et la ponctuation sous ses formes verticales.
- [Coupure des lignes en japonais (kinsoku)](https://postext.dev/fr/docs/japanese-layout.md#coupure-des-lignes-kinsoku): Le japonais coupe une ligne entre deux caractères quelconques, sauf là où la JLReq l’interdit : pas de crochet fermant, de 、。, de petit kana (ぁ ッ ャ), de ー, de marque d’itération (々 ゝ) ni de 〜 en début de ligne, pas de crochet ouvrant en fin de ligne, et ―― ou …… jamais coupés. Un document balisé ja prend le niveau le plus strict ; cjk.lineBreak choisit 'ja-strict' ou 'ja-loose' pour les livres qui laissent les petits kana et ー commencer une ligne.

**Utilise aussi**

- [Livres reliés à droite](https://postext.dev/fr/docs/configuration-page-layout.md#reliure)
- [Grille de caractères](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères)
- [Polices chinoises, japonaises et coréennes](https://postext.dev/fr/docs/configuration-programmatic-usage.md#polices-chinoises-japonaises-et-coréennes)
- [Colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)
- [Colonne marginale pour les flottants](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Sauts de page et de colonne](https://postext.dev/fr/docs/document-format.md#pagebreak)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Chapitres non numérotés](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`cjk`](https://postext.dev/fr/docs/configuration-east-asian.md#typographie-dasie-de-lest), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)

**API**

- [`buildDocument`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#construire-un-document), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), `loadVerticalAlternates`, [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf), [`withLoadedFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)

**Caractères**

- Klee One (OFL-1.1), Zen Maru Gothic (OFL-1.1)

## Préparation

### 1 · Garder chaque groupe entier

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/hiragana-picture-book.md#la-réponse-courte) plus haut. Le japonais peut se couper entre deux kana quelconques : une espace entre les groupes ajoute seulement de l’air et, seule, n’empêche pas une ligne de finir au milieu de あか|い. `cjk.wordBreak: 'keep-all'` ne garde que les coupures à une espace et à côté de la ponctuation, après 、。」 ou avant 「, comme le fait `word-break: keep-all` en CSS, et les règles de kinsoku s’appliquent toujours ([Typographie d’Asie orientale](https://postext.dev/fr/docs/configuration-east-asian.md#typographie-dasie-de-lest)). L’espace est un U+0020 ordinaire, de 0,35 em en Klee One, et la ligne l’abandonne quand il tombe en fin de ligne. Un U+3000 sépare aussi les groupes, mais c’est un caractère fixe d’un cadratin de large, conservé en fin de ligne.

![Page 3.](https://postext.dev/cookbook/hiragana-picture-book/en/p03.webp?v=1f767d0c)

*Page 3 : huit lignes, chacune finissant à une espace ou après 」 et 、 : 「いって / らっしゃい。」 / みいちゃんが / そっと てを…*

Le texte est composé en drapeau. Justifiée, une ligne courte étirerait ses espaces et les intervalles entre ses kana pour atteindre le pied de la bande ; un livre d’images arrête chaque ligne là où finit son dernier groupe.

### 2 · Mettre les images dans une bande au-dessus du texte

```js
// script.js, lignes 54–62
// In vertical text 'left' is the top of the page: the side column is a tier that takes
// the pictures (floats), and the text runs in the tier under it, right to left.
const layout = {
  layoutType: 'oneAndHalf', writingMode: 'vertical-rl', sideColumnRole: 'floats',
  sideColumnSide: 'left', sideColumnPercent: 58, gutterWidth: pt(2 * BODY),
};
const cjk = { ...phrases, grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
const resourceTypes = [{ id: 'picture', name: 'え', shortLabel: 'え', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // pictures, no captions
```

En écriture verticale, `'left'` désigne le haut de la page : une mise en page à colonne et demie dont la colonne latérale est à gauche devient donc deux bandes. Les images flottent dans la bande du haut (14 caractères de profondeur) et le texte court dans celle du bas (8 caractères). Chaque page cite son image avec `::resource{id="…"}` et se termine par `:::pagebreak`. Le type de ressource des images n’a pas de préfixe de légende, et les images sont 2,186 fois plus larges que hautes, la forme de la bande, si bien que chacune la remplit ([écriture verticale](https://postext.dev/fr/docs/configuration-page-layout.md#écriture-verticale)).

### 3 · Faire de la couverture une image pleine page

```js
// script.js, lignes 66–83
// In the flow frame of a vertical page x runs down the sheet and y leftward from its right
// edge; a box's width runs down the sheet.
const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const cover = {
  id: 'cover', numbered: false, toc: false, span: 'page', header: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(TRIM.w - 40), slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover', decorative: true,
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(TRIM.h), height: mm(TRIM.w) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(60), lineHeight: 1, letterSpacing: pt(6), color: col('boat'),
      placement: at(22, 34) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(9), lineHeight: 1.4, color: col('ink'), placement: at(22, 62) },
  ] } },
};
```

La couverture est une maquette de titre sur toute la largeur de la page : l’image est ancrée au fond perdu avec la taille de la page elle-même (dans le flux vertical, sa `width` descend le long de la feuille), et le titre descend le ciel, à droite, en Zen Maru Gothic de 60 pt.

### 4 · Charger les peintures comme bitmaps

```js
// script.js, lignes 188–203
// The scenes have the tier's shape, 612 : 280 (ART); the cover has the page's, 260 : 200.
const PICTURES = { // id: [file, width, height, alt text in the book's language]
  cover: ['cover-1430.jpg', 1430, 1100, 'あおい かわに あかい かみの ふねが うかんで いる。'],
  rain: ['rain-1400.jpg', 1400, 641, 'まどべの ちゃぶだいで、みいちゃんが あかい かみで ふねを おって いる。'],
  stream: ['stream-1400.jpg', 1400, 641, 'みいちゃんが おがわに ふねを ながし、てを ふって いる。'],
  frog: ['frog-1400.jpg', 1400, 641, 'かわの いしの うえの かえるが、ながれて いく ふねに はなしかけて いる。'],
  bridge: ['bridge-1400.jpg', 1400, 641, 'きの はしの したを ふねが くぐり、ちいさな さかなが ついて くる。'],
  heron: ['heron-1400.jpg', 1400, 641, 'ひろい かわの きしで、しろい さぎが ふねを みおくって いる。'],
  dusk: ['dusk-1400.jpg', 1400, 641, 'ももいろの ゆうやけの かわに ふねが うかび、とおくに うみが みえる。'],
  night: ['night-1400.jpg', 1400, 641, 'まんげつが かわに うつり、ひかりの みちの そばを ふねが すすむ。'],
  sea: ['sea-1400.jpg', 1400, 641, 'あおい うみに でた ふねの うえを、かもめが とんで いる。'],
  window: ['window-1400.jpg', 1400, 641, 'みいちゃんが まどから、まちの むこうの うみを みて いる。'],
};
const pictures = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({
  id, typeId: 'picture', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText,
  placement: { span: 'side' }, bitmap: { fileId, format: 'jpeg', width: w, height: h } }));
```

Les dix images sont des peintures, des fichiers JPEG rangés dans le dossier `assets/` de la recette. Les neuf scènes sont taillées à la forme de la bande, 612 : 280, et la couverture à celle de la page, 260 : 200, de sorte que chacune remplit son cadre sans aucun recadrage. Chacune est une ressource `bitmap` déclarée à sa taille en pixels, avec un texte alternatif dans la langue du livre, et son fichier est chargé sous le `fileId` de la ressource avant la composition. Larges de 1 400 px, les scènes s’impriment à environ 165 dpi sur la largeur de la bande ([types de ressources](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)).

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: https://github.com/drnachio/postext/tree/main/cookbook/hiragana-picture-book

### script.js

```js
// ═══ Postext Cookbook · Nº 127 · A hiragana picture book, spaced by phrase ══════════
// https://postext.dev/en/cookbook/hiragana-picture-book
// Code: MIT · Story: written for the recipe (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Klee One, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A picture book for small readers: hiragana only, a space between phrases, vertical.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the story is Japanese in both editions
const RECIPE = 'hiragana-picture-book';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the paper boat's red, river blues, a warm paper
const palette = {
  ink: '#2e2a26', // the text: a soft near-black
  boat: '#cf4430', // the boat, the title (4.6:1 on the paper)
  river: '#5d93ad',
  grass: '#86b665',
  muted: '#6e675f', // the colophon
  paper: '#fbf6ea',
};
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: 'boat (defaults)', value: { hex: palette.boat, model: 'hex' } },
];
// #endregion

const HAND = 'Klee One'; // 教科書体: the hand a child learns to write kana from
const ROUND = 'Zen Maru Gothic'; // the cover's title
const [BODY, LEAD] = [20, 36]; // pt: large kana, a line gap of 0.8 em
const PT = 25.4 / 72; // mm in a point
const TRIM = { w: 260, h: 200 }; // mm: a landscape picture book (横長)
const [CHARS, PICTURE, LINES] = [8, 14, 17]; // characters: text tier, picture tier; lines
const ART = { w: LINES * LEAD * PT, h: PICTURE * BODY * PT }; // mm: 215.9 × 98.8

// #region answer: わかち書き — a space between phrases, and lines that break only there
// The story is typed with an ordinary space (U+0020) after each phrase (文節). Japanese
// text may break between any two kana, so the space alone does not keep a phrase whole:
// keep-all drops the breaks between two letters and keeps those at a space, after 、。」
// and before 「. Kinsoku still applies.
const phrases = { wordBreak: 'keep-all' }; // cjk.wordBreak
const bodyText = {
  fontFamily: HAND, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // Ragged: a justified line would stretch its spaces and its kana to fill the tier.
  textAlign: 'left', firstLineIndent: em(0),
};
// #endregion

// #region layout: the picture tier above, eight characters of text below it
// In vertical text 'left' is the top of the page: the side column is a tier that takes
// the pictures (floats), and the text runs in the tier under it, right to left.
const layout = {
  layoutType: 'oneAndHalf', writingMode: 'vertical-rl', sideColumnRole: 'floats',
  sideColumnSide: 'left', sideColumnPercent: 58, gutterWidth: pt(2 * BODY),
};
const cjk = { ...phrases, grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
const resourceTypes = [{ id: 'picture', name: 'え', shortLabel: 'え', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // pictures, no captions
// #endregion

// #region cover: the first picture across the page, the title down its sky
// In the flow frame of a vertical page x runs down the sheet and y leftward from its right
// edge; a box's width runs down the sheet.
const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const cover = {
  id: 'cover', numbered: false, toc: false, span: 'page', header: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(TRIM.w - 40), slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover', decorative: true,
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(TRIM.h), height: mm(TRIM.w) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(60), lineHeight: 1, letterSpacing: pt(6), color: col('boat'),
      placement: at(22, 34) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(9), lineHeight: 1.4, color: col('ink'), placement: at(22, 62) },
  ] } },
};
// #endregion

const config = () => ({
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(14), bottom: mm(16), left: mm(22), right: mm(22), mirror: true },
  },
  layout,
  cjk,
  bodyText,
  headings: { fontFamily: ROUND, color: col('boat'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] }, // the H1 break
  headingStyles: [cover],
  paragraphStyles: [
    { id: 'end', fontFamily: ROUND, fontWeight: 700, color: col('boat'), textAlign: 'right',
      marginTop: pt(LEAD) }, // おしまい at the foot of its line
    { id: 'colophon', fontFamily: HAND, fontSize: pt(7.5), lineHeight: pt(LEAD),
      color: col('muted'), textAlign: 'left', marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, // a picture book has no running heads and no folios
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "かみの ふね"
author: "Postext Cookbook"
---

# かみの ふね {style="cover" latin="The Paper Boat: a picture book in hiragana"}

::resource{id="rain"}

あめが やんだ あさ、みいちゃんは あかい かみで ふねを おりました。

:::pagebreak

::resource{id="stream"}

「いって らっしゃい。」みいちゃんが そっと てを はなすと、ふねは すいすい ながれて いきました。

:::pagebreak

::resource{id="frog"}

いしの うえで、かえるが ききました。「どこへ いくの。」「うみまで。」と、ふねは こたえました。

:::pagebreak

::resource{id="bridge"}

はしの したを くぐると、ちいさな さかなが ついて きました。ぴちゃん、ぴちゃん。

:::pagebreak

::resource{id="heron"}

かわは だんだん ひろく なりました。きしでは さぎが いっぽんあしで みおくって います。

:::pagebreak

::resource{id="dusk"}

ゆうがた、そらが ももいろに そまりました。かぜの なかに、うみの においが します。

:::pagebreak

::resource{id="night"}

よるに なりました。おつきさまが みずに うつって、ふねの みちを てらして くれました。

:::pagebreak

::resource{id="sea"}

あさ、ふねは ひろい うみに でました。かもめが「ようこそ。」と いいました。

:::pagebreak

::resource{id="window"}

とおくの まちで、みいちゃんも まどを あけて、うみの ほうを みて いました。

:::paragraphs{style="end"}
おしまい
:::

:::paragraphs{style="colophon"}
Story written for this recipe (CC BY 4.0)

Pictures: paintings

Fonts: Klee One, Zen Maru Gothic (SIL OFL)
:::
`; // content.<lang>.md, a space after each phrase

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Klee One': ['400'], 'Zen Maru Gothic': ['700'] };

// #region pictures: ten paintings, declared at their pixel size under the file ids they load as
// The scenes have the tier's shape, 612 : 280 (ART); the cover has the page's, 260 : 200.
const PICTURES = { // id: [file, width, height, alt text in the book's language]
  cover: ['cover-1430.jpg', 1430, 1100, 'あおい かわに あかい かみの ふねが うかんで いる。'],
  rain: ['rain-1400.jpg', 1400, 641, 'まどべの ちゃぶだいで、みいちゃんが あかい かみで ふねを おって いる。'],
  stream: ['stream-1400.jpg', 1400, 641, 'みいちゃんが おがわに ふねを ながし、てを ふって いる。'],
  frog: ['frog-1400.jpg', 1400, 641, 'かわの いしの うえの かえるが、ながれて いく ふねに はなしかけて いる。'],
  bridge: ['bridge-1400.jpg', 1400, 641, 'きの はしの したを ふねが くぐり、ちいさな さかなが ついて くる。'],
  heron: ['heron-1400.jpg', 1400, 641, 'ひろい かわの きしで、しろい さぎが ふねを みおくって いる。'],
  dusk: ['dusk-1400.jpg', 1400, 641, 'ももいろの ゆうやけの かわに ふねが うかび、とおくに うみが みえる。'],
  night: ['night-1400.jpg', 1400, 641, 'まんげつが かわに うつり、ひかりの みちの そばを ふねが すすむ。'],
  sea: ['sea-1400.jpg', 1400, 641, 'あおい うみに でた ふねの うえを、かもめが とんで いる。'],
  window: ['window-1400.jpg', 1400, 641, 'みいちゃんが まどから、まちの むこうの うみを みて いる。'],
};
const pictures = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({
  id, typeId: 'picture', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText,
  placement: { span: 'side' }, bitmap: { fileId, format: 'jpeg', width: w, height: h } }));
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [HAND]: FONTS[HAND] }, markdown, { vertical: true });
await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, 'かみのふねおしまい', { vertical: true });
// A design slot or ::resource names a resource, the resource a file id: load each file.
await Promise.all(Object.values(PICTURES).map(([file]) => loadImage(file, asset(file))));
const doc = await withLoadedFonts(
  () => buildDocument({ markdown, resources: pictures }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A hiragana picture book',
  es: 'Un libro ilustrado en hiragana' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `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.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  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 at = fontsourceAt(id);
    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 = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** 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, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** 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 v2 ── 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. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its 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 files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  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 · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── Kit · cjk v2 ── 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 = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    let at;
    cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1)))
      .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], at).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` the screen sets code point `cp` in, if any: the
 *  last one loadCjkFonts adds whose range holds it. A numbered file that
 *  repeats a letter of latin is added after latin, and wins it. */
function cjkSliceFor(slices, cp) {
  const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
  return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined);
}

/** 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 the engine's later round
 *  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 the
 *  screen set its pages' characters in (`request.codePoints`), each marked
 *  with its unicode-range and its place in the order loadCjkFonts added
 *  them: postext-pdf sets a character two files hold in the one added
 *  last, as the browser does. 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.map(async (slice, order) => {
    if (!picked.has(slice)) return null;
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    return Object.assign(bytes, { unicodeRange: slice.range, order });
  })).then((files) => files.filter(Boolean));
}

/** 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, each marked with its unicode-range and its place in that order:
 *  the PDF sets a character in the file the browser took it from. 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 = `${await fontsourceAt(id)}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)]);
  const ranges = kitRanges();
  return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }),
    Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Justifier les lignes

Justifiée, chaque ligne atteint le pied de la bande : les espaces entre les groupes s’étirent d’abord, jusqu’à un demi-cadratin, puis les intervalles entre les kana (JLReq §3.8). Une ligne courte de kana de 20 pt montre alors de larges blancs, et c’est pourquoi la recette laisse ses lignes en drapeau.

```diff
-  textAlign: 'left', firstLineIndent: em(0),
+  textAlign: 'justify', firstLineIndent: em(0),
```

## Erreurs fréquentes

- **Balisez un texte japonais 'ja', jamais zh-Hans ni LANG.** Les éditions d’une recette sont en et es, mais un exemple japonais est en japonais dans les deux : `locale: LANG` le baliserait en anglais ou en espagnol, et une balise chinoise le composerait selon les règles chinoises (ponctuation Kaiming, petits kana libres de commencer une ligne, 图 pour 図, formes de glyphes chinoises dans le PDF). Depuis postext 1.30, la balise est aussi la langue dans laquelle le texte est mis en forme (mesuré, peint et incorporé) : toute balise autre que 'ja' donne d’autres formes de glyphes que les japonaises (les chinoises sous une balise chinoise) et d’autres coupures de ligne. Écrivez 'ja' : elle choisit la région Japon (coupure des lignes et ponctuation JLReq, points d’emphase sésame, espacement des furigana, étiquettes 図 et 表) et désactive la césure. Le lint fait échouer un texte contenant des kana sous une balise zh ou ko.
- **Composez le japonais dans un caractère japonais.** Noto Serif SC et TC ont des kana, mais ils dessinent les kanji sous leurs formes chinoises (直, 骨 et 角 diffèrent) et les kana dans un dessin chinois. Composez le texte en Noto Serif JP ou Shippori Mincho B1 et les titres en Noto Sans JP, chargés avec loadCjkFonts. Les fichiers japonais de Fontsource ne contiennent ni hentaigana ni autres kana historiques (U+1B000–1B16F) : loadCjkFonts échoue sur eux et le PDF imprime des cases. Écrivez donc les kana modernes, ou livrez dans les ressources de la recette un caractère qui les possède. Shippori Mincho n’a pas non plus les voyelles à macron ō et ū : composez le rōmaji dans un caractère latin.
- **Les polices chinoises se chargent par tranches, avec le bloc cjk.** Fontsource sert une famille chinoise, japonaise ou coréenne sous la forme d’une centaine de fichiers par graisse, chacun couvrant une plage de caractères. loadFonts ne récupère que le fichier latin : à l’écran, les caractères han viennent d’une police système et sont mal mesurés, et fontsourceProvider remet ce fichier latin au PDF, qui les imprime en cases vides. Listez le bloc cjk du kit, appelez loadCjkFonts(FONTS, markdown) après loadFonts (une fois par voix, avec le texte qu’elle compose, quand le livre utilise plusieurs polices CJK) et donnez à renderToPdf fontProvider: cjkPdfProvider : les deux prennent les fichiers qui contiennent les caractères du texte.

- `keep-all` garde les coupures que le texte possède déjà : Postext ne trouve pas les groupes, c’est l’auteur qui tape les espaces. Un groupe plus long que la ligne se coupe quand même à l’intérieur, au dernier endroit que permettent les règles de kinsoku.
- Le chinois n’espace jamais ses mots, et l’abécédaire chinois [Un livre de lecture vertical avec le zhuyin à droite](https://postext.dev/fr/cookbook/zhuyin-vertical-reader.md) aide plutôt ses jeunes lecteurs par des lectures ; un livre d’images japonais n’emploie aucun kanji, il n’a donc pas besoin de furigana, et ce sont les espaces entre les groupes qui guident la lecture.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: かみの ふね (The Paper Boat), a picture-book story in hiragana written for this recipe: Postext Cookbook, CC-BY-4.0
- Images: The cover: a red paper boat on a river through green meadows, a clear sky on the right for the title: Generated With Diffusion Models, original
- Images: Mii-chan folding the red paper boat at a low table by the window, after the rain: Generated With Diffusion Models, original
- Images: Mii-chan waving goodbye to the boat on the stream: Generated With Diffusion Models, original
- Images: A frog on a stone talking to the boat: Generated With Diffusion Models, original
- Images: The boat under a wooden footbridge, little fish following it: Generated With Diffusion Models, original
- Images: A white heron on the bank watching the boat on the wide river: Generated With Diffusion Models, original
- Images: Dusk: a pink sky over the river mouth and the sea beyond: Generated With Diffusion Models, original
- Images: Night: the full moon's reflection lighting the boat's way: Generated With Diffusion Models, original
- Images: The boat at sea, greeted by seagulls: Generated With Diffusion Models, original
- Images: Mii-chan at her window, looking towards the distant sea: Generated With Diffusion Models, original
- Caractères: Klee One (OFL-1.1), Zen Maru Gothic (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 080 · Un livre de lecture vertical avec zhuyin à droite](https://postext.dev/fr/cookbook/zhuyin-vertical-reader.md): Un livre de lecture scolaire taïwanais composé à la verticale : {守株|ㄕㄡˇ|ㄓㄨ} pose une colonne de zhuyin à droite de chaque caractère, images et notes en haut. · Niveau 3 (Avancé) · Manuels scolaires
- [Nº 076 · Un abécédaire en pinyin : la lecture sur chaque caractère](https://postext.dev/fr/cookbook/pinyin-primer.md): Ruby mono dans un livre de lecture de Hong Kong : {人之初|rén zhī chū} centre une syllabe pinyin sur chaque caractère, en Andika, dans un interligne de 28 pt. · Niveau 2 (Intermédiaire) · Manuels scolaires, Cahiers et exercices
- [Nº 066 · Chapitres d’album illustré avec vignettes et sommaires](https://postext.dev/fr/cookbook/storybook-chapter-vignettes.md): Un seul style de titre ouvre chaque chapitre ; chaque titre nomme dans ses attributs sa vignette et son sommaire en italique, que l’ouverture peint. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
