# Deux yonkoma sur une planche

> Une planche de gags en quatre cases : deux colonnes de quatre cases, la bande de droite lue en premier, chacune titrée dans un cartouche noir en première case.

- Version HTML: https://postext.dev/fr/cookbook/yonkoma
- Recette Nº 151 · Bande dessinée et manga · Niveau 2 (Intermédiaire) · Sorties: Canvas, PDF
- Genres: Bande dessinée
- Nécessite postext ≥ 1.25.1, postext-pdf ≥ 1.25.1 · testée avec 1.33.4, postext-pdf 1.33.4 le 2026-10-15
- Pages: [1](https://postext.dev/cookbook/yonkoma/fr/p01.webp?v=a14de6ec), [1](https://postext.dev/cookbook/yonkoma/fr/p02.webp?v=a14de6ec)
- PDF: https://postext.dev/cookbook/yonkoma/fr/yonkoma.pdf?v=a14de6ec
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=yonkoma&lang=fr (.postext: https://postext.dev/cookbook/yonkoma/fr/yonkoma.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/yonkoma.md), [es](https://postext.dev/es/cookbook/yonkoma.md), [ca](https://postext.dev/ca/cookbook/yonkoma.md), [pt](https://postext.dev/pt/cookbook/yonkoma.md), [zh](https://postext.dev/zh/cookbook/yonkoma.md), [ja](https://postext.dev/ja/cookbook/yonkoma.md), [ar](https://postext.dev/ar/cookbook/yonkoma.md)

## En bref

Une planche de courtes bandes dessinées japonaises de quatre cases chacune, côte à côte comme les imprime un magazine de manga : on lit la bande de droite de haut en bas, puis celle de gauche.

## Ce que vous allez composer

Une planche d’une anthologie de yonkoma, ねこ喫茶まどろみ (*Café Roupillon*), écrite pour la recette et dessinée dans un style chibi tout en douceur : Mugi tient un café à chats avec un chat blanc, un chat noir et une chatte calico. Les yonkoma, bandes de quatre cases, sont le strip humoristique des journaux tourné d’un quart de tour : quatre cases égales empilées de haut en bas, exposition, développement, retournement, chute. Une planche de magazine en porte deux côte à côte, et comme la planche s’ouvre par la droite, la bande de droite vient en premier. La planche est en A5, 148 × 210 mm, avec l’original japonais d’un côté de la double page et l’édition dans la langue de cette page de l’autre (l’anglais sur la page japonaise).

**Cette recette répond à:**

- Comment placer côte à côte deux yonkoma de quatre cases, la bande de droite lue en premier ?

## La réponse courte

```js
// script.js, lignes 44–62
// content.<lang>.md splits the page `* [* / * / * / *] | * [* / * / * / *]`: two equal
// columns, each cut into four equal panels. The reading direction lays the first column
// out on the right, so the strip that opens the page is the right-hand one, as in a
// yonkoma magazine, and each strip still reads top to bottom. The gutter between the
// strips is wider than the gutters between their panels, so the eye drops down a strip
// before it crosses to the next.
const comics = (lang) => ({
  artDirection: 'rtl', // the right strip first, in every edition
  mirrorArt: false,
  gutter: { horizontal: mm(3), vertical: mm(9) }, // 3 mm between panels, 9 mm between strips
  panel: { borderWidth: pt(0.9), borderColor: col('ink'), background: col('paper') },
  lettering: {
    fontFamily: EDITIONS[lang].faces[0], color: col('ink'),
    fontSize: pt(['ja', 'zh'].includes(lang) ? 8 : 7), // a yonkoma letters smaller than a story
    writingMode: lang === 'zh' ? 'vertical' : 'auto',
    maxColumnChars: 6, // short columns: a yonkoma panel is wider than it is tall
  },
  balloonStyles: balloons(),
});
```

## Ingrédients

**Enseigne**

- [Découpage des cases](https://postext.dev/fr/docs/comics.md#découper-la-planche): split= découpe la page en cases avec une expression courte : des bandes empilées avec /, des cases côte à côte avec |, des tailles en pourcentage, des gouttières obliques avec 30~40, et des crochets pour redécouper une case.
- [Sens de lecture et ordre manga](https://postext.dev/fr/docs/comics.md#sens-de-lecture): Les cases remplissent les cellules dans l’ordre de lecture : de gauche à droite, ou de droite à gauche pour les mangas et pour les langues qui s’écrivent de droite à gauche ; comics.artDirection et mirrorArt décident quand l’image est retournée en conséquence.

**Utilise aussi**

- [Texte vertical](https://postext.dev/fr/docs/configuration-page-layout.md#écriture-verticale)
- [Polices chinoises, japonaises et coréennes](https://postext.dev/fr/docs/configuration-programmatic-usage.md#polices-chinoises-japonaises-et-coréennes)
- [Bordures et gouttières des cases](https://postext.dev/fr/docs/comics.md#cases-et-dessins)
- [Images des cases et recadrages](https://postext.dev/fr/docs/comics.md#cases-et-dessins)
- [Relettrage dans d’autres langues](https://postext.dev/fr/docs/comics.md#lettrage)
- [Personnages et ancres](https://postext.dev/fr/docs/comics.md#locuteurs-et-ancres)
- [Planches de bande dessinée](https://postext.dev/fr/docs/comics.md#planches)
- [Lettrage de bande dessinée](https://postext.dev/fr/docs/comics.md#lettrage)
- [Styles de bulle](https://postext.dev/fr/docs/comics.md#styles-de-bulles)
- [Onomatopées](https://postext.dev/fr/docs/comics.md#styles-de-bulles)
- [Livres reliés à droite](https://postext.dev/fr/docs/configuration-page-layout.md#reliure)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Coupure des lignes en chinois](https://postext.dev/fr/docs/configuration-east-asian.md#typographie-dasie-de-lest)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`comics`](https://postext.dev/fr/docs/comics.md#planches), [`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), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page)

**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**

- Zen Antique (OFL-1.1), Dela Gothic One (OFL-1.1), Comic Neue (OFL-1.1), Bangers (OFL-1.1), Noto Sans SC (OFL-1.1), ZCOOL KuaiLe (OFL-1.1), Playpen Sans Arabic (OFL-1.1), Lalezar (OFL-1.1)

## Préparation

### 1 · Découper la planche en deux colonnes de quatre cases

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/yonkoma.md#la-réponse-courte) plus haut. Toute la planche est un seul `:::page` avec `split="* [* / * / * / *] | * [* / * / * / *]"` : deux colonnes égales, chacune découpée en quatre étages égaux. Les huit lignes `::panel` remplissent les cellules dans l’ordre de lecture : les quatre premières appartiennent donc à la première bande, et avec une lecture de droite à gauche la première colonne se place à droite ([découpage de la planche](https://postext.dev/fr/docs/comics.md#découper-la-planche)). La gouttière entre les bandes fait 9 mm et celle entre les cases 3 mm, si bien que l’œil descend une bande avant de passer à la suivante.

### 2 · Titrer chaque bande dans sa première case

```js
// script.js, lignes 66–73
const balloons = () => [
  { id: 'speech', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.6), roundness: 2.4 },
  { id: 'shout', fill: col('paper'), stroke: col('ink'), burstPoints: 16 },
  // caption{title}: the strip's title, white on black in the first panel's corner
  { id: 'title', shape: 'rectangle', fill: col('ink'), stroke: col('ink'), color: col('paper'),
    bold: true, tail: 'none', position: 'top-start', butt: true, align: 'center' },
  { id: 'sfx', color: col('ink'), haloColor: col('paper'), fontScale: 1.8 }, // chibi: small effects
];
```

Un yonkoma a un titre, et une anthologie l’imprime dans un cartouche en tête de la bande. Ici, le titre est une ligne du script, `caption{title}: 朝ごはん`, et `title` est un style de bulle à part entière : blanc sur noir, collé à la bordure de la case dans le coin supérieur de début, qui est le coin supérieur droit dans ce livre. Il est lettré dans chaque édition comme n’importe quelle autre ligne : la page française affiche donc *Petit-déjeuner* et *Thérapie* dans les mêmes cartouches.

### 3 · Marquer aussi les chats comme personnages qui parlent

```js
// script.js, lignes 207–330
const cat = (id, x, y, fx, fy, w = 0.12, h = 0.15) => ({ id, x, y,
  face: { x: fx, y: fy, width: w, height: h } });
const nose = (id, x, y) => ({ id, x, y, head: { x, y: y - 0.04 } }); // cats asleep or eating
const ART = { // fractions of the picture: the same in every language
  door: {
    alt: t({ en: "Mugi unlocks the café's glass door; the white, black and calico cats wait.",
      es: 'Mugi abre la puerta de cristal del café; dentro esperan los tres gatos.',
      ca: 'La Mugi obre la porta de vidre del cafè; a dins esperen els tres gats.',
      zh: '麦打开猫咖的玻璃门，三只猫在里面等着。',
      ar: 'موغي تفتح باب المقهى الزجاجي، والقطط الثلاث تنتظر في الداخل.',
      ja: '猫カフェのガラス戸を開けるむぎ。中で3匹の猫が待っている。',
      fr: 'Mugi ouvre la porte vitrée du café ; à l’intérieur, les trois chats attendent.',
      pt: 'Mugi abre a porta de vidro do café; lá dentro, os três gatos esperam.' }),
  safeArea: { x: 0.09, y: 0.19, width: 0.81, height: 0.52 },
  anchors: [{ id: 'mugi', x: 0.205, y: 0.385, head: { x: 0.17, y: 0.25 },
    face: { x: 0.1, y: 0.2, width: 0.2, height: 0.23 } },
  cat('shiro', 0.5, 0.66, 0.44, 0.55, 0.13), cat('kuro', 0.66, 0.62, 0.61, 0.52, 0.12, 0.14),
  cat('mike', 0.83, 0.645, 0.77, 0.53, 0.12, 0.16)] },
  bowls: {
    alt: t({ en: 'Mugi sets out full food bowls in front of the three cats.',
      es: 'Mugi pone cuencos llenos delante de los tres gatos.',
      ca: 'La Mugi posa bols plens de menjar davant dels tres gats.',
      zh: '麦把装满猫粮的碗摆在三只猫面前。',
      ar: 'موغي تضع صحون طعام ممتلئة أمام القطط الثلاث.',
      ja: '3匹の猫の前に、ご飯を盛った器を並べるむぎ。',
      fr: 'Mugi pose des bols pleins de nourriture devant les trois chats.',
      pt: 'Mugi põe tigelas cheias de comida na frente dos três gatos.' }),
  safeArea: { x: 0.16, y: 0.26, width: 0.74, height: 0.65 },
  anchors: [{ id: 'mugi', x: 0.27, y: 0.465, head: { x: 0.25, y: 0.3 },
    face: { x: 0.17, y: 0.26, width: 0.16, height: 0.24 } },
  cat('shiro', 0.37, 0.63, 0.31, 0.52, 0.13, 0.16), cat('kuro', 0.6, 0.64, 0.55, 0.52, 0.11, 0.16),
  cat('mike', 0.82, 0.62, 0.76, 0.52, 0.12, 0.14)] },
  onebowl: {
    alt: t({ en: 'All three cats push their heads into the same bowl; the others stay full.',
      es: 'Los tres gatos meten la cabeza en el mismo cuenco; los otros siguen llenos.',
      ca: 'Els tres gats fiquen el cap al mateix bol; els altres continuen plens.',
      zh: '三只猫把头挤进同一个碗里，另外两个碗还满着。',
      ar: 'القطط الثلاث تدسّ رؤوسها في الصحن نفسه، والصحنان الآخران ممتلئان.',
      ja: '3匹そろって同じ器に頭を突っこみ、ほかの器は手つかず。',
      fr: 'Les trois chats plongent la tête dans le même bol ; les autres restent pleins.',
      pt: 'Os três gatos enfiam a cabeça na mesma tigela; as outras continuam cheias.' }),
  safeArea: { x: 0.05, y: 0.27, width: 0.88, height: 0.66 },
  anchors: [nose('shiro', 0.33, 0.66), nose('kuro', 0.5, 0.6), nose('mike', 0.66, 0.68),
    { id: 'sfx', x: 0.5, y: 0.3 }] },
  toast: {
    alt: t({ en: 'Mugi bites her toast while the three cats stare at it with shining eyes.',
      es: 'Mugi muerde su tostada mientras los tres gatos la miran con ojos brillantes.',
      ca: 'La Mugi mossega la torrada mentre els tres gats la miren amb ulls brillants.',
      zh: '麦咬着吐司，三只猫眼睛发亮地盯着。',
      ar: 'موغي تقضم خبزها المحمّص والقطط الثلاث تحدّق فيه بعيون لامعة.',
      ja: 'トーストをかじるむぎを、3匹がきらきらした目で見つめる。',
      fr: 'Mugi croque sa tartine tandis que les trois chats la fixent, les yeux brillants.',
      pt: 'Mugi morde a torrada enquanto os três gatos olham para ela com olhos brilhando.' }),
  safeArea: { x: 0.09, y: 0.22, width: 0.85, height: 0.56 },
  avoid: [{ x: 0.6, y: 0.52, width: 0.15, height: 0.1 }],
  anchors: [{ id: 'mugi', x: 0.8, y: 0.5, head: { x: 0.85, y: 0.32 },
    face: { x: 0.75, y: 0.25, width: 0.2, height: 0.33 } },
  cat('shiro', 0.17, 0.71, 0.1, 0.6, 0.14, 0.16), cat('kuro', 0.32, 0.73, 0.26, 0.62, 0.12, 0.14),
  cat('mike', 0.46, 0.66, 0.4, 0.56, 0.12, 0.14)] },
  guest: {
    alt: t({ en: 'A tired office worker walks in, arms out; Mugi greets him from the counter.',
      es: 'Un oficinista cansado entra con los brazos abiertos; Mugi lo saluda desde la barra.',
      ca: 'Un oficinista cansat entra amb els braços oberts; la Mugi el saluda des de la barra.',
      zh: '一个疲惫的上班族张着双臂走进来，麦在柜台后招呼他。',
      ar: 'موظف متعب يدخل فاتحًا ذراعيه، وموغي ترحّب به من خلف المنضدة.',
      ja: '両手を広げて入ってくる疲れた会社員と、カウンターから迎えるむぎ。',
      fr: 'Un employé de bureau fatigué entre les bras ouverts ; Mugi l’accueille depuis le '
        + 'comptoir.',
      pt: 'Um funcionário de escritório cansado entra de braços abertos; Mugi o recebe do '
        + 'balcão.' }),
  safeArea: { x: 0.13, y: 0.14, width: 0.74, height: 0.51 },
  anchors: [{ id: 'kyaku', x: 0.31, y: 0.42, head: { x: 0.28, y: 0.3 },
    face: { x: 0.19, y: 0.22, width: 0.21, height: 0.26 } },
  { id: 'mugi', x: 0.76, y: 0.39, head: { x: 0.76, y: 0.28 },
    face: { x: 0.68, y: 0.2, width: 0.18, height: 0.25 } }] },
  nap: {
    alt: t({ en: 'The three cats asleep in one pile on a round cushion.',
      es: 'Los tres gatos dormidos en un montón sobre un cojín redondo.',
      ca: 'Els tres gats adormits en un munt sobre un coixí rodó.',
      zh: '三只猫挤成一团睡在圆垫子上。',
      ar: 'القطط الثلاث نائمة كومةً واحدة على وسادة مستديرة.',
      ja: '丸いクッションの上で、ひとかたまりになって眠る3匹。',
      fr: 'Les trois chats dorment en tas sur un coussin rond.',
      pt: 'Os três gatos dormem amontoados numa almofada redonda.' }),
  safeArea: { x: 0.27, y: 0.21, width: 0.52, height: 0.55 },
  anchors: [nose('shiro', 0.4, 0.42), nose('kuro', 0.41, 0.7), nose('mike', 0.72, 0.58),
    { id: 'sfx', x: 0.3, y: 0.25 }] },
  laptop: {
    alt: t({ en: 'The office worker sighs at a café table and opens his laptop.',
      es: 'El oficinista suspira en una mesa del café y abre el portátil.',
      ca: 'L’oficinista sospira en una taula del cafè i obre el portàtil.',
      zh: '上班族在咖啡桌前叹了口气，打开笔记本电脑。',
      ar: 'الموظف يتنهّد إلى طاولة في المقهى ويفتح حاسوبه.',
      ja: 'カフェのテーブルでため息をつき、ノートパソコンを開く会社員。',
      fr: 'L’employé soupire à une table du café et ouvre son ordinateur portable.',
      pt: 'O funcionário suspira numa mesa do café e abre o notebook.' }),
  safeArea: { x: 0.22, y: 0.25, width: 0.56, height: 0.55 },
  anchors: [{ id: 'kyaku', x: 0.45, y: 0.62, head: { x: 0.39, y: 0.4 },
    face: { x: 0.3, y: 0.3, width: 0.22, height: 0.38 } }] },
  keyboard: {
    alt: t({ en: 'The three cats sit on the keyboard; the worker cries tears of joy.',
      es: 'Los tres gatos se sientan en el teclado; el oficinista llora de alegría.',
      ca: 'Els tres gats seuen damunt del teclat; l’oficinista plora d’alegria.',
      zh: '三只猫坐在键盘上，上班族喜极而泣。',
      ar: 'القطط الثلاث تجلس على لوحة المفاتيح، والموظف يبكي من الفرح.',
      ja: 'キーボードの上に座る3匹と、うれし泣きする会社員。',
      fr: 'Les trois chats s’assoient sur le clavier ; l’employé pleure de joie.',
      pt: 'Os três gatos sentam no teclado; o funcionário chora de alegria.' }),
  safeArea: { x: 0.07, y: 0.1, width: 0.81, height: 0.78 },
  anchors: [{ id: 'kyaku', x: 0.29, y: 0.42, head: { x: 0.25, y: 0.2 },
    face: { x: 0.15, y: 0.1, width: 0.27, height: 0.4 } },
  cat('shiro', 0.42, 0.57, 0.36, 0.48, 0.13, 0.14), cat('kuro', 0.59, 0.6, 0.53, 0.5, 0.12, 0.14),
  cat('mike', 0.74, 0.62, 0.68, 0.52, 0.12, 0.14)] },
};
const panels = () => Promise.all([
  comicPanel('door', asset('neko-door.jpg'), ART.door),
  comicPanel('bowls', asset('neko-bowls.jpg'), ART.bowls),
  comicPanel('onebowl', asset('neko-onebowl.jpg'), ART.onebowl),
  comicPanel('toast', asset('neko-toast.jpg'), ART.toast),
  comicPanel('guest', asset('neko-guest.jpg'), ART.guest),
  comicPanel('nap', asset('neko-nap.jpg'), ART.nap),
  comicPanel('laptop', asset('neko-laptop.jpg'), ART.laptop),
  comicPanel('keyboard', asset('neko-keyboard.jpg'), ART.keyboard),
]);
```

Les chats parlent, `kuro: にゃ` : chaque image marque donc leur bouche et leur visage comme elle marque ceux de Mugi. Deux petites fonctions abrègent la liste : `cat()` pour un chat tourné vers le lecteur et `nose()` pour un chat dont le visage est enfoui dans une gamelle ou un coussin. Dans la troisième case, Mugi est hors champ, et sa réplique `mugi{tail=top}` reçoit un appendice qui va jusqu’au bord supérieur.

### 4 · Charger les polices de chaque édition avec ses mots

```js
// script.js, lignes 334–346
const SFX = /^sfx[^:：\n]*[:：](.*)$/gm; // sound-effect lines: the second face sets them
async function loadEdition(lang) {
  const text = TEXT[lang];
  const [face, sfx] = EDITIONS[lang].faces;
  const faces = { [face]: FONTS[face], [sfx]: FONTS[sfx] };
  if (['ja', 'zh'].includes(lang)) { // CJK: only the files that hold these characters
    const effects = [...text.matchAll(SFX)].map((m) => m[1]).join('');
    await loadCjkFonts({ [face]: FONTS[face] }, text.replace(SFX, ''), { vertical: true });
    await loadCjkFonts({ [sfx]: FONTS[sfx] }, effects, { vertical: true });
  }
  if (lang === 'ar') await loadArabicFonts(faces, text);
  await loadComicFonts(faces, text); // the bold and italic the faces do not ship
}
```

Chaque police reçoit les mots qu’elle compose : Zen Antique les bulles, Dela Gothic One les onomatopées (ガツガツ, じーっ, すやぁ, ずしっ). Une case de yonkoma est plus large que haute : les colonnes japonaises s’arrêtent donc à six caractères (`maxColumnChars: 6`) et le lettrage est d’un demi-point plus petit que dans une planche de récit.

### 5 · Placer l’original à côté de chaque édition

```js
// script.js, lignes 351–361
const FACING = LANG === 'ja' ? 'en' : LANG; // Japanese readers see the English edition
const TEXT = { ja: original, [FACING]: LANG === 'ja' ? english : markdown };
await loadFonts(FONTS, Object.values(TEXT).join('\n'));
const resources = await panels();
const build = async (lang) => { // the same pictures and page; this language's lettering
  await loadEdition(lang);
  return withLoadedFonts(() => buildDocument({ markdown: TEXT[lang], resources,
    continuation: { pageIndexOffset: lang === 'ja' ? 1 : 2 } }, edition(lang)),
    { ...kitFonts(FONTS), text: TEXT[lang] });
};
const docs = [await build('ja'), await build(FACING)];
```

Chaque langue du site lettre les bandes dans son propre fichier, face à l’original japonais sur la double page ; la page japonaise fait face à l’édition anglaise. L’ordre reste le même dans toutes les langues : la bande de droite d’abord.

## 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/yonkoma

### script.js

```js
// ═══ Postext Cookbook · Nº 151 · Two yonkoma strips on a page ═════════════════════════
// https://postext.dev/en/cookbook/yonkoma
// Code: MIT · Story: written for the recipe (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Zen Antique, Comic Neue and six more (SIL OFL 1.1) · Needs postext ≥ 1.25.1
// A page of a cat-café yonkoma: two four-panel strips side by side, the right one first.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'fr'; // @lang: this edition's language; the Japanese original faces it
const RECIPE = 'yonkoma';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black ink on a warm white, one screen-tone grey
const palette = {
  ink: '#151413', // panel borders, balloon outlines, the lettering
  paper: '#fdfcf9', // the page and the balloons
  tone: '#e8e6e1', // the inner-voice box, a light screen tone
  muted: '#6f6b64',
  accent: '#a8322b', // the cover red (the Folio case in recipe.json)
};
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: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion

// The engine's lettering and sound-effect faces per language (defaultComicFont and
// defaultComicSfxFont), written out so FONTS and the PDF know them.
const EDITIONS = {
  ja: { locale: 'ja', faces: ['Zen Antique', 'Dela Gothic One'] },
  en: { locale: 'en-us', faces: ['Comic Neue', 'Bangers'] },
  es: { locale: 'es', faces: ['Comic Neue', 'Bangers'] },
  ca: { locale: 'ca', faces: ['Comic Neue', 'Bangers'] },
  zh: { locale: 'zh-Hans', faces: ['Noto Sans SC', 'ZCOOL KuaiLe'] },
  ar: { locale: 'ar', faces: ['Playpen Sans Arabic', 'Lalezar'] },
  pt: { locale: 'pt-BR', faces: ['Comic Neue', 'Bangers'] },
  fr: { locale: 'fr', faces: ['Comic Neue', 'Bangers'] },
};

// #region answer: two columns of four, read from the right
// content.<lang>.md splits the page `* [* / * / * / *] | * [* / * / * / *]`: two equal
// columns, each cut into four equal panels. The reading direction lays the first column
// out on the right, so the strip that opens the page is the right-hand one, as in a
// yonkoma magazine, and each strip still reads top to bottom. The gutter between the
// strips is wider than the gutters between their panels, so the eye drops down a strip
// before it crosses to the next.
const comics = (lang) => ({
  artDirection: 'rtl', // the right strip first, in every edition
  mirrorArt: false,
  gutter: { horizontal: mm(3), vertical: mm(9) }, // 3 mm between panels, 9 mm between strips
  panel: { borderWidth: pt(0.9), borderColor: col('ink'), background: col('paper') },
  lettering: {
    fontFamily: EDITIONS[lang].faces[0], color: col('ink'),
    fontSize: pt(['ja', 'zh'].includes(lang) ? 8 : 7), // a yonkoma letters smaller than a story
    writingMode: lang === 'zh' ? 'vertical' : 'auto',
    maxColumnChars: 6, // short columns: a yonkoma panel is wider than it is tall
  },
  balloonStyles: balloons(),
});
// #endregion

// #region balloons: round speech, and a black box for each strip's title
const balloons = () => [
  { id: 'speech', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.6), roundness: 2.4 },
  { id: 'shout', fill: col('paper'), stroke: col('ink'), burstPoints: 16 },
  // caption{title}: the strip's title, white on black in the first panel's corner
  { id: 'title', shape: 'rectangle', fill: col('ink'), stroke: col('ink'), color: col('paper'),
    bold: true, tail: 'none', position: 'top-start', butt: true, align: 'center' },
  { id: 'sfx', color: col('ink'), haloColor: col('paper'), fontScale: 1.8 }, // chibi: small effects
];
// #endregion

const config = () => ({
  // Each edition's language, written out (gotcha: ja-locale-tag); the original is built
  // with 'ja' in every edition (edition('ja') below).
  locale: t({ ja: 'ja', en: 'en-us', es: 'es', ca: 'ca', zh: 'zh-Hans', ar: 'ar', pt: 'pt-BR',
    fr: 'fr' }),
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5, a yonkoma anthology
    binding: 'right', backgroundColor: col('paper'), // manga open from the right, in any language
    margins: { top: mm(15), bottom: mm(15), left: mm(14), right: mm(14), mirror: true },
  },
  bodyText: {
    fontFamily: 'Zen Antique', fontSize: pt(9), lineHeight: pt(15), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  },
  headings: { fontFamily: 'Zen Antique', color: col('ink'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] }, // the H1 break
  header: { elements: [] }, // comic pages are all panels: no running heads, no folios
  footer: { elements: [] },
  comics: comics(LANG),
});
// Another edition changes the language and the faces; the strips and their order stay.
const edition = (lang) => ({ ...config(), locale: EDITIONS[lang].locale, comics: comics(lang) });

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Café Roupillon"
author: "Postext Cookbook"
---

:::page{split="* [* / * / * / *] | * [* / * / * / *]"}
::panel{art=door}
caption{title}: Petit-déjeuner
mugi: Bonjour, tout le monde !
kuro: Miaou.
::panel{art=bowls}
mugi: Un bol chacun, d’accord ?
::panel{art=onebowl}
sfx: MIAM MIAM
mugi{tail=top}: …Pourquoi tous dans le même ?
::panel{art=toast}
sfx: FIXE
mugi: Vous venez de manger !
::panel{art=guest}
caption{title}: Thérapie
kyaku: Je viens me faire soigner par les chats…
mugi: Bienvenue !
::panel{art=nap}
sfx: zzz
kyaku{tail=top}: …Ils dorment tous.
::panel{art=laptop}
kyaku: Bon. Je vais travailler un peu…
::panel{art=keyboard}
sfx{at="70% 80%" rotate=-10}: POUF !
kyaku{shout}: Impossible de bosser. Trop heureux !!
:::
`; // content.<lang>.md: this edition's lettering
const original = String.raw`---
title: "ねこ喫茶まどろみ"
author: "Postext Cookbook"
---

:::page{split="* [* / * / * / *] | * [* / * / * / *]"}
::panel{art=door}
caption{title}: 朝ごはん
mugi: おはよー、みんな！
kuro: にゃ
::panel{art=bowls}
mugi: ひとり一個ずつね
::panel{art=onebowl}
sfx: ガツガツ
mugi{tail=top}: ……なんで一個に集まるの
::panel{art=toast}
sfx: じーっ
mugi: さっき食べたでしょ！
::panel{art=guest}
caption{title}: いやし
kyaku: 猫ちゃんに癒されに来ました……
mugi: いらっしゃいませー
::panel{art=nap}
sfx: すやぁ
kyaku{tail=top}: ……全員寝てる
::panel{art=laptop}
kyaku: しかたない、仕事するか……
::panel{art=keyboard}
sfx{at="86% 84%" rotate=-10}: ずしっ
kyaku{shout break}: 仕事できないけど幸せーっ！！
:::
`; // content.ja.md, the original, in every edition
const english = String.raw`---
title: "Café Doze"
author: "Postext Cookbook"
---

:::page{split="* [* / * / * / *] | * [* / * / * / *]"}
::panel{art=door}
caption{title}: Breakfast
mugi: Morning, everyone!
kuro: Mrrp.
::panel{art=bowls}
mugi: One bowl each, okay?
::panel{art=onebowl}
sfx: NOM NOM
mugi{tail=top}: …Why all in the same one?
::panel{art=toast}
sfx: STARE
mugi: You just ate!
::panel{art=guest}
caption{title}: Therapy
kyaku: I'm here to be healed by cats…
mugi: Welcome!
::panel{art=nap}
sfx: zzz
kyaku{tail=top}: …They're all asleep.
::panel{art=laptop}
kyaku: Fine. I'll get some work done…
::panel{art=keyboard}
sfx{at="70% 80%" rotate=-10}: THUMP
kyaku{shout}: Can't work. So happy!!
:::
`; // the Japanese edition faces the original with it
// The same panels and speaker ids in every file; only the words change.

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Zen Antique': ['400'], 'Dela Gothic One': ['400'], 'Comic Neue': ['400', '700', '400i', '700i'],
  Bangers: ['400'], 'Noto Sans SC': ['400', '700'], 'ZCOOL KuaiLe': ['400'],
  'Playpen Sans Arabic': ['400', '700'], Lalezar: ['400'],
};

// #region art: eight chibi panels drawn for the recipe, speakers and faces marked once
const cat = (id, x, y, fx, fy, w = 0.12, h = 0.15) => ({ id, x, y,
  face: { x: fx, y: fy, width: w, height: h } });
const nose = (id, x, y) => ({ id, x, y, head: { x, y: y - 0.04 } }); // cats asleep or eating
const ART = { // fractions of the picture: the same in every language
  door: {
    alt: t({ en: "Mugi unlocks the café's glass door; the white, black and calico cats wait.",
      es: 'Mugi abre la puerta de cristal del café; dentro esperan los tres gatos.',
      ca: 'La Mugi obre la porta de vidre del cafè; a dins esperen els tres gats.',
      zh: '麦打开猫咖的玻璃门，三只猫在里面等着。',
      ar: 'موغي تفتح باب المقهى الزجاجي، والقطط الثلاث تنتظر في الداخل.',
      ja: '猫カフェのガラス戸を開けるむぎ。中で3匹の猫が待っている。',
      fr: 'Mugi ouvre la porte vitrée du café ; à l’intérieur, les trois chats attendent.',
      pt: 'Mugi abre a porta de vidro do café; lá dentro, os três gatos esperam.' }),
  safeArea: { x: 0.09, y: 0.19, width: 0.81, height: 0.52 },
  anchors: [{ id: 'mugi', x: 0.205, y: 0.385, head: { x: 0.17, y: 0.25 },
    face: { x: 0.1, y: 0.2, width: 0.2, height: 0.23 } },
  cat('shiro', 0.5, 0.66, 0.44, 0.55, 0.13), cat('kuro', 0.66, 0.62, 0.61, 0.52, 0.12, 0.14),
  cat('mike', 0.83, 0.645, 0.77, 0.53, 0.12, 0.16)] },
  bowls: {
    alt: t({ en: 'Mugi sets out full food bowls in front of the three cats.',
      es: 'Mugi pone cuencos llenos delante de los tres gatos.',
      ca: 'La Mugi posa bols plens de menjar davant dels tres gats.',
      zh: '麦把装满猫粮的碗摆在三只猫面前。',
      ar: 'موغي تضع صحون طعام ممتلئة أمام القطط الثلاث.',
      ja: '3匹の猫の前に、ご飯を盛った器を並べるむぎ。',
      fr: 'Mugi pose des bols pleins de nourriture devant les trois chats.',
      pt: 'Mugi põe tigelas cheias de comida na frente dos três gatos.' }),
  safeArea: { x: 0.16, y: 0.26, width: 0.74, height: 0.65 },
  anchors: [{ id: 'mugi', x: 0.27, y: 0.465, head: { x: 0.25, y: 0.3 },
    face: { x: 0.17, y: 0.26, width: 0.16, height: 0.24 } },
  cat('shiro', 0.37, 0.63, 0.31, 0.52, 0.13, 0.16), cat('kuro', 0.6, 0.64, 0.55, 0.52, 0.11, 0.16),
  cat('mike', 0.82, 0.62, 0.76, 0.52, 0.12, 0.14)] },
  onebowl: {
    alt: t({ en: 'All three cats push their heads into the same bowl; the others stay full.',
      es: 'Los tres gatos meten la cabeza en el mismo cuenco; los otros siguen llenos.',
      ca: 'Els tres gats fiquen el cap al mateix bol; els altres continuen plens.',
      zh: '三只猫把头挤进同一个碗里，另外两个碗还满着。',
      ar: 'القطط الثلاث تدسّ رؤوسها في الصحن نفسه، والصحنان الآخران ممتلئان.',
      ja: '3匹そろって同じ器に頭を突っこみ、ほかの器は手つかず。',
      fr: 'Les trois chats plongent la tête dans le même bol ; les autres restent pleins.',
      pt: 'Os três gatos enfiam a cabeça na mesma tigela; as outras continuam cheias.' }),
  safeArea: { x: 0.05, y: 0.27, width: 0.88, height: 0.66 },
  anchors: [nose('shiro', 0.33, 0.66), nose('kuro', 0.5, 0.6), nose('mike', 0.66, 0.68),
    { id: 'sfx', x: 0.5, y: 0.3 }] },
  toast: {
    alt: t({ en: 'Mugi bites her toast while the three cats stare at it with shining eyes.',
      es: 'Mugi muerde su tostada mientras los tres gatos la miran con ojos brillantes.',
      ca: 'La Mugi mossega la torrada mentre els tres gats la miren amb ulls brillants.',
      zh: '麦咬着吐司，三只猫眼睛发亮地盯着。',
      ar: 'موغي تقضم خبزها المحمّص والقطط الثلاث تحدّق فيه بعيون لامعة.',
      ja: 'トーストをかじるむぎを、3匹がきらきらした目で見つめる。',
      fr: 'Mugi croque sa tartine tandis que les trois chats la fixent, les yeux brillants.',
      pt: 'Mugi morde a torrada enquanto os três gatos olham para ela com olhos brilhando.' }),
  safeArea: { x: 0.09, y: 0.22, width: 0.85, height: 0.56 },
  avoid: [{ x: 0.6, y: 0.52, width: 0.15, height: 0.1 }],
  anchors: [{ id: 'mugi', x: 0.8, y: 0.5, head: { x: 0.85, y: 0.32 },
    face: { x: 0.75, y: 0.25, width: 0.2, height: 0.33 } },
  cat('shiro', 0.17, 0.71, 0.1, 0.6, 0.14, 0.16), cat('kuro', 0.32, 0.73, 0.26, 0.62, 0.12, 0.14),
  cat('mike', 0.46, 0.66, 0.4, 0.56, 0.12, 0.14)] },
  guest: {
    alt: t({ en: 'A tired office worker walks in, arms out; Mugi greets him from the counter.',
      es: 'Un oficinista cansado entra con los brazos abiertos; Mugi lo saluda desde la barra.',
      ca: 'Un oficinista cansat entra amb els braços oberts; la Mugi el saluda des de la barra.',
      zh: '一个疲惫的上班族张着双臂走进来，麦在柜台后招呼他。',
      ar: 'موظف متعب يدخل فاتحًا ذراعيه، وموغي ترحّب به من خلف المنضدة.',
      ja: '両手を広げて入ってくる疲れた会社員と、カウンターから迎えるむぎ。',
      fr: 'Un employé de bureau fatigué entre les bras ouverts ; Mugi l’accueille depuis le '
        + 'comptoir.',
      pt: 'Um funcionário de escritório cansado entra de braços abertos; Mugi o recebe do '
        + 'balcão.' }),
  safeArea: { x: 0.13, y: 0.14, width: 0.74, height: 0.51 },
  anchors: [{ id: 'kyaku', x: 0.31, y: 0.42, head: { x: 0.28, y: 0.3 },
    face: { x: 0.19, y: 0.22, width: 0.21, height: 0.26 } },
  { id: 'mugi', x: 0.76, y: 0.39, head: { x: 0.76, y: 0.28 },
    face: { x: 0.68, y: 0.2, width: 0.18, height: 0.25 } }] },
  nap: {
    alt: t({ en: 'The three cats asleep in one pile on a round cushion.',
      es: 'Los tres gatos dormidos en un montón sobre un cojín redondo.',
      ca: 'Els tres gats adormits en un munt sobre un coixí rodó.',
      zh: '三只猫挤成一团睡在圆垫子上。',
      ar: 'القطط الثلاث نائمة كومةً واحدة على وسادة مستديرة.',
      ja: '丸いクッションの上で、ひとかたまりになって眠る3匹。',
      fr: 'Les trois chats dorment en tas sur un coussin rond.',
      pt: 'Os três gatos dormem amontoados numa almofada redonda.' }),
  safeArea: { x: 0.27, y: 0.21, width: 0.52, height: 0.55 },
  anchors: [nose('shiro', 0.4, 0.42), nose('kuro', 0.41, 0.7), nose('mike', 0.72, 0.58),
    { id: 'sfx', x: 0.3, y: 0.25 }] },
  laptop: {
    alt: t({ en: 'The office worker sighs at a café table and opens his laptop.',
      es: 'El oficinista suspira en una mesa del café y abre el portátil.',
      ca: 'L’oficinista sospira en una taula del cafè i obre el portàtil.',
      zh: '上班族在咖啡桌前叹了口气，打开笔记本电脑。',
      ar: 'الموظف يتنهّد إلى طاولة في المقهى ويفتح حاسوبه.',
      ja: 'カフェのテーブルでため息をつき、ノートパソコンを開く会社員。',
      fr: 'L’employé soupire à une table du café et ouvre son ordinateur portable.',
      pt: 'O funcionário suspira numa mesa do café e abre o notebook.' }),
  safeArea: { x: 0.22, y: 0.25, width: 0.56, height: 0.55 },
  anchors: [{ id: 'kyaku', x: 0.45, y: 0.62, head: { x: 0.39, y: 0.4 },
    face: { x: 0.3, y: 0.3, width: 0.22, height: 0.38 } }] },
  keyboard: {
    alt: t({ en: 'The three cats sit on the keyboard; the worker cries tears of joy.',
      es: 'Los tres gatos se sientan en el teclado; el oficinista llora de alegría.',
      ca: 'Els tres gats seuen damunt del teclat; l’oficinista plora d’alegria.',
      zh: '三只猫坐在键盘上，上班族喜极而泣。',
      ar: 'القطط الثلاث تجلس على لوحة المفاتيح، والموظف يبكي من الفرح.',
      ja: 'キーボードの上に座る3匹と、うれし泣きする会社員。',
      fr: 'Les trois chats s’assoient sur le clavier ; l’employé pleure de joie.',
      pt: 'Os três gatos sentam no teclado; o funcionário chora de alegria.' }),
  safeArea: { x: 0.07, y: 0.1, width: 0.81, height: 0.78 },
  anchors: [{ id: 'kyaku', x: 0.29, y: 0.42, head: { x: 0.25, y: 0.2 },
    face: { x: 0.15, y: 0.1, width: 0.27, height: 0.4 } },
  cat('shiro', 0.42, 0.57, 0.36, 0.48, 0.13, 0.14), cat('kuro', 0.59, 0.6, 0.53, 0.5, 0.12, 0.14),
  cat('mike', 0.74, 0.62, 0.68, 0.52, 0.12, 0.14)] },
};
const panels = () => Promise.all([
  comicPanel('door', asset('neko-door.jpg'), ART.door),
  comicPanel('bowls', asset('neko-bowls.jpg'), ART.bowls),
  comicPanel('onebowl', asset('neko-onebowl.jpg'), ART.onebowl),
  comicPanel('toast', asset('neko-toast.jpg'), ART.toast),
  comicPanel('guest', asset('neko-guest.jpg'), ART.guest),
  comicPanel('nap', asset('neko-nap.jpg'), ART.nap),
  comicPanel('laptop', asset('neko-laptop.jpg'), ART.laptop),
  comicPanel('keyboard', asset('neko-keyboard.jpg'), ART.keyboard),
]);
// #endregion

// #region fonts: each edition loads its two faces with the words they set
const SFX = /^sfx[^:：\n]*[:：](.*)$/gm; // sound-effect lines: the second face sets them
async function loadEdition(lang) {
  const text = TEXT[lang];
  const [face, sfx] = EDITIONS[lang].faces;
  const faces = { [face]: FONTS[face], [sfx]: FONTS[sfx] };
  if (['ja', 'zh'].includes(lang)) { // CJK: only the files that hold these characters
    const effects = [...text.matchAll(SFX)].map((m) => m[1]).join('');
    await loadCjkFonts({ [face]: FONTS[face] }, text.replace(SFX, ''), { vertical: true });
    await loadCjkFonts({ [sfx]: FONTS[sfx] }, effects, { vertical: true });
  }
  if (lang === 'ar') await loadArabicFonts(faces, text);
  await loadComicFonts(faces, text); // the bold and italic the faces do not ship
}
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region editions: the original on the right-hand page, this edition's lettering facing it
const FACING = LANG === 'ja' ? 'en' : LANG; // Japanese readers see the English edition
const TEXT = { ja: original, [FACING]: LANG === 'ja' ? english : markdown };
await loadFonts(FONTS, Object.values(TEXT).join('\n'));
const resources = await panels();
const build = async (lang) => { // the same pictures and page; this language's lettering
  await loadEdition(lang);
  return withLoadedFonts(() => buildDocument({ markdown: TEXT[lang], resources,
    continuation: { pageIndexOffset: lang === 'ja' ? 1 : 2 } }, edition(lang)),
    { ...kitFonts(FONTS), text: TEXT[lang] });
};
const docs = [await build('ja'), await build(FACING)];
// #endregion
const TITLE = t({
  en: 'Two yonkoma strips on a page', es: 'Dos tiras yonkoma en una página',
  ca: 'Dues tires yonkoma en una pàgina', zh: '一页并排两篇四格漫画', ar: 'شريطا يونكوما في صفحة واحدة',
  ja: '1ページに四コマを2本並べる', pt: 'Duas tiras yonkoma numa página',
  fr: 'Deux strips yonkoma sur une page' });
showBook(docs, { title: TITLE });
offerPdf(() => renderToPdf(docs, { fontProvider: comicPdfProvider, 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 · arabic v2 ── Arabic-script faces · postext.dev/cookbook
// Fontsource ships an Arabic family as one file per subset and weight: the
// `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the
// Arabic punctuation and the presentation forms; `latin` and `latin-ext`
// hold the rest. loadFonts loads the latin files; this block adds the arabic
// file of every Arabic family, for the canvas and for the PDF, which shapes
// the letters with HarfBuzz from the same bytes.

/** The code points of Fontsource's `arabic` subset, as its stylesheets
 *  declare them (the same unicode-range the browser picks the file by). A
 *  function, not a const: the kit is inlined after the recipe's top-level
 *  awaits, and a const read before its line throws, where a function
 *  declaration is hoisted. */
function arabicRange() {
  return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,'
    + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,'
    + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF';
}

/** Whether code point `cp` is in the arabic file. */
function inArabicRange(cp) {
  inArabicRange.ranges ??= arabicRange().split(',').map((part) => {
    const [lo, hi = lo] = part.slice(2).split('-');
    return [parseInt(lo, 16), parseInt(hi, 16)];
  });
  return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
}

/** Whether Fontsource serves `family` with an `arabic` subset. Fails when
 *  the API does not answer: an Arabic face taken for a Latin one would set
 *  its letters in a system face. */
async function isArabicFamily(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?.includes('arabic');
}

/** The arabic file of a face. */
async function arabicFileUrl(family, weight, style) {
  const id = fontsourceId(family);
  return `${await fontsourceAt(id)}files/${id}-arabic-${weight}-${style}.woff2`;
}

/** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole
 *  FONTS object may be passed, its families without an arabic subset are
 *  left alone. Adds the arabic file of every listed weight of each Arabic
 *  family (the latin files come from loadFonts) and loads it. `text` is
 *  the sample: fails when it holds an Arabic-script character the arabic
 *  file does not cover. List every weight the pages set in Arabic: a weight
 *  left to the engine's later round gets the latin file only, and its Arabic letters
 *  fall back to a system face. Resolves to the number of files loaded. */
async function loadArabicFonts(faces, text = '') {
  kitStatus('Loading fonts…');
  let loaded = 0;
  try {
    const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0)));
    if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`);
    for (const [family, specs] of Object.entries(faces)) {
      if (!(await isArabicFamily(family))) continue;
      for (const spec of new Set(specs)) {
        const weight = parseInt(spec, 10);
        const style = spec.endsWith('i') ? 'italic' : 'normal';
        const face = new FontFace(family, `url(${await arabicFileUrl(family, weight, style)}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: arabicRange() });
        document.fonts.add(await face.load().catch(() => {
          throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`);
        }));
        loaded++;
      }
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return loaded;
}

/** The PDF font provider for recipes with Arabic faces: a family with an
 *  arabic subset gets its arabic file when its pages set Arabic letters
 *  (`request.codePoints`), then its latin file, and its latin-ext file for
 *  the letters beyond latin (transliteration: ā ḥ ʿ), each marked with its
 *  unicode-range and its place in the order the screen added them (latin
 *  and latin-ext by loadFonts, then arabic): the arabic file also has a
 *  space and brackets, which the browser takes from latin, the file whose
 *  range holds them, and postext-pdf follows it. Any other family goes to
 *  fontsourceProvider (the "pdf" block). */
async function arabicPdfProvider(family, weight, style, request) {
  if (!(await isArabicFamily(family))) return fontsourceProvider(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 wanted = [...(request?.codePoints ?? [])];
  const id = fontsourceId(family);
  const subsets = !wanted.length || wanted.some(inArabicRange) ? ['arabic', 'latin'] : ['latin'];
  if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) {
    subsets.push('latin-ext');
  }
  const at = await fontsourceAt(id);
  return Promise.all(subsets.map(async (subset) => {
    const url = `${at}files/${id}-${subset}-${w}-${s}.woff2`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`);
    const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    return Object.assign(bytes, { unicodeRange: subset === 'arabic' ? arabicRange() : kitRanges()[subset],
      order: ['latin', 'latin-ext', 'greek', 'arabic'].indexOf(subset) });
  }));
}

// ─── Kit · comics v2 ── comic pages · postext.dev/cookbook
// The engine letters a comic in a face per language (defaultComicFont:
// Comic Neue, Zen Antique for Japanese, Noto Sans SC, LXGW WenKai TC,
// Playpen Sans Arabic; defaultComicSfxFont: Bangers, Dela Gothic One,
// ZCOOL KuaiLe, Lalezar) and asks for the bold of a shout or a sound effect
// and the italic of an inner voice. Most of these faces ship one weight and
// no italic: this block declares the file Fontsource does ship for those
// variants, so the canvas measures and paints the letters the PDF embeds
// (the providers snap to the shipped file) and not a bold or a slant the
// browser makes up. It also turns the art manifest into panel resources.

/** faces = { 'Comic Neue': ['400', '700'], Bangers: ['400'] }, as for
 *  loadFonts, after it (and after loadCjkFonts or loadArabicFonts for the
 *  faces of those scripts); the whole FONTS object may be passed. List in
 *  FONTS only what Fontsource ships (Bangers: ['400']): loadFonts fails on
 *  the rest. For each family, the regular, bold, italic and bold italic it
 *  lacks are declared with its nearest file (the regular for the bold, the
 *  upright for the italic) and loaded for `text`. A CJK face needs the cjk
 *  block; an Arabic face setting Arabic, the arabic block. Resolves to the
 *  number of variants added. */
async function loadComicFonts(faces, text = '') {
  kitStatus('Loading fonts…');
  let added = 0;
  try {
    for (const family of Object.keys(faces)) {
      const meta = await fontsourceMeta(family);
      if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`);
      const weights = meta.weights?.length ? meta.weights : [400, 700];
      const styles = meta.styles?.length ? meta.styles : ['normal'];
      for (const [weight, style] of [[400, 'normal'], [700, 'normal'], [400, 'italic'], [700, 'italic']]) {
        if ((weights.includes(weight) && styles.includes(style)) || hasFace(family, weight, style)) continue;
        const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
        const s = style === 'italic' && styles.includes('italic') ? 'italic' : 'normal';
        for (const { url, range } of await comicFaceFiles(family, w, s, meta, text)) {
          document.fonts.add(new FontFace(family, `url(${url}) format('woff2')`,
            { weight: String(weight), style, unicodeRange: range }));
        }
        await document.fonts.load(`${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`, text || 'A');
        added++;
      }
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return added;
}

/** The files of a shipped face ({ url, range }): a CJK face's slices (the
 *  cjk block reads them from its stylesheet), else latin, the latin-ext
 *  and greek files `text` needs, and the arabic file when `text` holds
 *  Arabic. */
async function comicFaceFiles(family, weight, style, meta, text) {
  if (meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset))) {
    if (typeof cjkSlices !== 'function') throw new Error(`${family} is a CJK face: list the cjk kit block`);
    return (await cjkSlices(family, weight, style)).map(({ url, range }) => ({ url, range }));
  }
  const id = fontsourceId(family);
  const at = await fontsourceAt(id);
  const file = (subset) => `${at}files/${id}-${subset}-${weight}-${style}.woff2`;
  const files = ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ url: file(subset), range: comicRange(subset) }));
  if (meta.subsets?.includes('arabic') && /\p{Script=Arabic}/u.test(text)) {
    if (typeof arabicRange !== 'function') throw new Error(`${family} sets Arabic: list the arabic kit block`);
    files.unshift({ url: file('arabic'), range: arabicRange() });
  }
  return files;
}

/** The unicode-range loadFonts gives a Fontsource file. A function, not a
 *  const: the kit is inlined after the recipe's top-level awaits. */
function comicRange(subset) {
  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',
  }[subset];
}

/** The PDF font provider of a comic in any script: a CJK face goes to
 *  cjkPdfProvider, an Arabic face to arabicPdfProvider, any other to
 *  fontsourceProvider (the "pdf" block). Each embeds the shipped weight
 *  and style nearest to the one asked for, the file loadComicFonts
 *  declared for the canvas. */
async function comicPdfProvider(family, weight, style, request) {
  const subsets = (await fontsourceMeta(family))?.subsets ?? [];
  if (subsets.some((subset) => /^(chinese|japanese|korean)/.test(subset))) {
    if (typeof cjkPdfProvider !== 'function') throw new Error(`${family} is a CJK face: list the cjk kit block`);
    return cjkPdfProvider(family, weight, style, request);
  }
  if (subsets.includes('arabic') && typeof arabicPdfProvider === 'function') {
    return arabicPdfProvider(family, weight, style, request);
  }
  return fontsourceProvider(family, weight, style, request);
}

/** A panel picture as a resource for art= (and pop=): loads `url`
 *  (write asset('lh-arrive.jpg') in the call, so the lint checks the file)
 *  and declares it with `meta`, its entry in the art manifest:
 *  { width, height, alt, safeArea, anchors, avoid, faces }. The safe area
 *  is what every crop keeps, the anchors the speakers' mouths, heads and
 *  faces, the avoid zones what no balloon covers unless a panel has no
 *  other room, and `faces` the faces of the people who do not speak, which
 *  no balloon ever covers; all in fractions of the picture, the same in
 *  every language. Without width and height, the picture's own size. Needs
 *  the images block. Resolves to the resource. */
async function comicPanel(id, url, meta = {}) {
  const fileId = decodeURIComponent(url.split('/').pop());
  await loadImage(fileId, url);
  let { width, height } = meta;
  if (!width || !height) {
    const bitmap = await createImageBitmap(new Blob([imageBytes(fileId)]));
    ({ width, height } = bitmap);
    bitmap.close();
  }
  return {
    id, typeId: meta.typeId ?? 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId, format: /\.png$/i.test(fileId) ? 'png' : 'jpeg', width, height },
    ...(meta.alt && { altText: meta.alt }),
    ...(meta.safeArea && { safeArea: meta.safeArea }),
    ...(meta.anchors?.length && { anchors: meta.anchors }),
    ...(meta.avoid?.length && { avoid: meta.avoid }),
    ...(meta.faces?.length && { faces: meta.faces }),
  };
}

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

## 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.
- **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.

- Dans une petite case, une bulle verticale manque de hauteur avant de manquer de largeur : le moteur la compose alors en colonnes plus nombreuses et plus courtes, en coupant entre les mots. Le dernier cri de l’employée reste trop haut pour la case du bas en japonais : il est donc marqué `break` (`kyaku{shout break}`), et il crève le cadre vers la case du dessus au lieu de couvrir les chats, sans entrer dans la marge de la page.
- Une grande onomatopée se tient à l’écart des visages comme une bulle, et son point d’ancrage se trouve dans la zone sûre de la case, si bien que tout recadrage la garde : « POUF ! » se place à `70% 80%`, sur les pattes des chats. Le ずしっ japonais se compose à la verticale et il est plus haut ; à ce point-là, il couvrait le visage de la calico et la composition émettait l’avertissement `comicBalloonOverflow` : il se place donc à `86% 84%`, à côté d’elle.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: ねこ喫茶まどろみ (Café Doze), two yonkoma strips written for this recipe, with their English, Spanish, Catalan, Chinese and Arabic letterings: Postext Cookbook, CC-BY-4.0
- Images: Mugi unlocking the cat café's door, three cats waiting inside: Generated With Diffusion Models, original
- Images: Mugi setting out food bowls for the cats: Generated With Diffusion Models, original
- Images: Three cats eating from one bowl: Generated With Diffusion Models, original
- Images: The cats staring at Mugi's toast: Generated With Diffusion Models, original
- Images: A tired office worker arriving at the café: Generated With Diffusion Models, original
- Images: The three cats asleep on one cushion: Generated With Diffusion Models, original
- Images: The office worker opening his laptop: Generated With Diffusion Models, original
- Images: The cats sitting on the keyboard, the worker in happy tears: Generated With Diffusion Models, original
- Caractères: Zen Antique (OFL-1.1), Dela Gothic One (OFL-1.1), Comic Neue (OFL-1.1), Bangers (OFL-1.1), Noto Sans SC (OFL-1.1), ZCOOL KuaiLe (OFL-1.1), Playpen Sans Arabic (OFL-1.1), Lalezar (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 149 · Un manga lu de droite à gauche, lettré à la verticale](https://postext.dev/fr/cookbook/manga-right-to-left.md): Deux planches de manga dessinées pour une lecture de droite à gauche : artDirection rtl garde l’ordre des cases partout, et le japonais se lit en colonnes. · Niveau 2 (Intermédiaire) · Bande dessinée
- [Nº 127 · Un livre d’images en hiragana, espacé par groupes](https://postext.dev/fr/cookbook/hiragana-picture-book.md): 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. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire, Livres de photographie
- [Nº 153 · Une planche lettrée en six langues](https://postext.dev/fr/cookbook/one-page-six-languages.md): Une planche de BD, découpage et images fixes, lettrée par langue : la locale choisit le caractère ; japonais vertical, japonais et arabe de droite à gauche. · Niveau 3 (Avancé) · Bande dessinée
