# 15 — Export PDF : « les pages en PDF », pour tous les formats qui s'impriment

**Statut : accepté · Phase : P6 à P8 · Mis à jour : 24/10/2026.**

Accepté le 24/10/2026 (questions 1 à 6), questions 7 à 9 ouvertes. Phases PDF0 à PDF5 (§6) : PDF5
couvre les autres formats, chacun dans sa propre phase.

Ce document traite une demande transverse : pouvoir exporter les pages en PDF, et en particulier le
livre (`book`, book.recit.dev) et le webtoon (`toon`, webtoon.recit.dev), qui **doivent** l'avoir.
Il couvre aussi `report`, `deck` (qui a déjà un export, à unifier) et, en option, les pages et
sections de doc (`docs` et les autres sites). Les formats en cours de planification (`glossary` et
`directory` en `12-glossaire-annuaire.md`, `atlas` et `::map` en `13-cartes.md`, `form` en
`10-formulaires.md`) prennent le même chemin dans leur propre phase.

Il dépend de `02-architecture.md` (modèle `Story`), de `03-packaging.md` (groupes de packages), de
`06-roadmap.md` (P6 à P8), de `07-domaines.md` §4 et §5 (composants de site, pas de rendu lourd sur
Vercel), de `08-formats.md` (§0, §1, §2, §3, §9) et de `09-langues-et-json.md` (JSON typé, langues).
Chaque choix porte une **recommandation**. Les questions 1 à 6 du §8 ont été tranchées le
24/10/2026, sans l'utilisateur et à sa demande (décisions réversibles) ; les questions 7 à 9 restent
ouvertes.

Deux règles du projet, rappelées par l'utilisateur, encadrent tout le document :

1. **Tout Markdown a un jumeau JSON.** Une option d'export écrite dans le frontmatter existe à
   l'identique dans le JSON de la story, validée par le même schéma.
2. **Un seul système de types, cohérent entre packages.** Chaque option est un schéma zod de
   `@recit/core`, son type TS en est dérivé (`z.infer`, `z.output`), et aucun package ne redéfinit
   un type partagé.

## 1. L'idée et ce qu'elle apporte

**Une story Récit s'imprime comme elle se lit : la même source produit un PDF fidèle, paginé selon
son format, avec signets, liens, texte sélectionnable et métadonnées tirées de la story, par une
seule chaîne et une seule commande, `recit export --to pdf`.**

- **« Écrire une fois, raconter partout » jusqu'au papier.** Le PDF est le format d'échange qui
  survit à tout : envoi par mail, archivage, impression, lecture hors ligne. Un livre sans PDF n'est
  pas un livre, et un webtoon sans PDF ne se partage qu'en lien.
- **Une chaîne, pas neuf.** Aujourd'hui, seul le deck sait sortir un PDF, avec un code et des types
  qui lui sont propres. Sans plan commun, chaque format réinventerait son lanceur de Chromium, son
  signal « prêt », sa feuille d'impression et ses options. Ce plan fixe une seule chaîne : le format
  ne fournit **que sa mise en page imprimable**, tout le reste est partagé.
- **Les options sont des données.** Taille de page, marges, table des matières, en-têtes : tout est
  typé une fois, s'écrit dans le frontmatter ou dans le JSON, se valide dans l'éditeur via le JSON
  Schema publié, et se surcharge en ligne de commande avec le même schéma.
- **Accessible par défaut.** PDF balisé et signets activés par défaut, `lang` et `dir` posés depuis
  la locale : un PDF Récit doit pouvoir être lu par un lecteur d'écran.
- **Les sites en profitent.** Les fichiers générés en CI (jamais sur Vercel) sont listés dans un
  manifeste d'exports typé, que lit l'`ExportPanel` des sites de format (`07-domaines.md` §4).

### Liste fermée des formats qui exportent en PDF

On ne promet que ce qui a une mise en page imprimable définie. Les autres formats répondent par une
erreur explicite (`format "motion" has no PDF export; use "recit render"`), jamais par une capture
devinée.

| Format                  | PDF ?   | Forme du PDF                                                               | Phase                                     |
| ----------------------- | ------- | -------------------------------------------------------------------------- | ----------------------------------------- |
| `deck`                  | oui     | une page par slide (et une page de notes en option), taille du design      | P6 (PDF0, PDF1)                           |
| `report`                | oui     | A4 ou Letter, page de titre, table des matières, tableaux paginés          | P6 (PDF1)                                 |
| `toon`                  | **oui** | pages de hauteur fixe, coupées entre les cases (§3.5)                      | P7 (PDF2)                                 |
| `book`                  | **oui** | livre paginé : chapitres, table des matières, notes, renvois, index (§3.6) | P8 (PDF3)                                 |
| `docs`                  | option  | une page, ou une section de la sidebar (§3.7)                              | après P8, optionnel (PDF4)                |
| `roadmap`, `calendar`   | oui     | frise, mois ou agenda (spec dans leur phase)                               | P9–P10                                    |
| `glossary`, `directory` | oui     | liste A–Z, annexe (`12-glossaire-annuaire.md`, DI5.5)                      | phase de `12-glossaire-annuaire.md`       |
| `atlas` et bloc `::map` | oui     | SVG net à l'impression (`13-cartes.md`)                                    | bloc : lot P6 (GE1 à GE8) ; `atlas` : P15 |
| `form`                  | oui     | version imprimable avec QR code et URL (`10-formulaires.md`, FO6.1)        | plus tard (FO6.1), après P13              |
| `motion`                | **non** | vidéo : `recit render`                                                     | —                                         |
| `stories`               | **non** | images et vidéo                                                            | —                                         |

## 2. Existant et constats

### 2.1 Ce que le code fait aujourd'hui

- `packages/cli/src/commands/export-pdf.ts`, propre au deck :
  1. `buildProject()` dans un dossier temporaire ;
  2. serveur statique `serveStatic()` (http natif, port 0) ;
  3. Chromium via Playwright, ouverture de `?print=1[&notes=1]` ;
  4. attente de `window.recitReady === true` (60 s par défaut) ;
  5. `page.pdf({ width, height, printBackground: true, preferCSSPageSize: false })` aux dimensions
     du design en pixels.

  Le thème est imposé par `addInitScript` (localStorage `recit-theme`). Playwright est une
  dépendance pair optionnelle de `@recit/cli` (`>=1.50`, `1.63.0` en dev). `loadPlaywright()` essaie
  `playwright`, puis `playwright-core`, puis relance avec `channel: "chrome"`, c'est-à-dire le
  Chrome installé sur la machine, de version quelconque.

- Les types `PlaywrightPage`, `PlaywrightBrowser` et `PlaywrightModule` sont écrits à la main dans
  la CLI. `PlaywrightPage.pdf` n'y déclare ni `tagged` ni `outline`.
- `PdfExportOptions` (`output`, `configPath`, `locale`, `notes`, `theme: "light" | "dark"`,
  `timeout`) est un type TS manuel, sans schéma zod. Son champ `theme` redéfinit `ThemeMode`
  (`ThemeModeSchema` dans `packages/core/src/config/validate.ts`). `PdfExportResult.pageCount` vaut
  `build.totalSlides`.
- `packages/cli/src/commands/export-html.ts` : HTML autonome du deck, avertissement au-delà de 10
  Mo.
- `packages/cli/src/index.ts` (l. 162-214) : une seule commande `export [file]` (défaut `slides.md`,
  description « Export the deck »), PDF par défaut, `--html`, `--notes`, `--theme`, `--locale`,
  `-c`. Pas de sous-commande, pas d'aiguillage sur `format:`.
- Mode impression du deck :
  - `packages/formats/deck/src/deck/navigation-utils.ts` : `DeckMode` inclut `"print"`, déduit de
    `?print=1` ou `?mode=print` ;
  - `packages/formats/deck/src/deck/Presentation.tsx` (l. 265-305) : une `.print-slide` par slide,
    `revealAll`, `.print-notes` en option, `onPrintReady` quand toutes les slides sont prêtes et
    `document.fonts.ready` résolu ;
  - `packages/formats/deck/src/runtime/DeckApp.tsx` pose `window.recitReady = true`. Ce contrat
    n'existe que dans le deck.
- `packages/theme/src/print.css` : `@media print { @page { margin: 0 } }` global, sauts sur
  `.slide-container`, `.print-slide` et `.print-notes`, animations coupées, UI du deck masquée,
  `pre-wrap` sur le code. Seul `packages/cli/src/bundle/css.ts` l'importe (et le service worker du
  deck). Le fichier est donc propre au deck, mais `@recit/theme` l'exporte comme s'il était
  générique. Ses commentaires sont en français (règle : code en anglais).
- `packages/formats/{paged,book,toon,report,roadmap,calendar,stories}` et `packages/{data,time}`
  sont des stubs (`export {}`, privés, `0.0.0`). La description de `@recit/paged` ne cite que report
  et book.
- `packages/core/src/schema/source.ts` : seuls `DeckSource` et `DocsSource` ont des réglages typés.
  `DeckSettingsSourceSchema` porte les clés du frontmatter à plat (hors `meta`). `report`, `toon`,
  `book` et les autres passent par `genericSource()` (`looseObject`).
- `packages/core/src/config/validate.ts` (zod/mini) : `RecitConfigSchema` est découpé par format
  (`deck:`, `motion:`), sans section `pdf`, `print` ou `export`. La section du site, qui publie les
  récits, est `publish:` (`PublishConfigSchema`) ; `stories:` (`StoriesConfigSchema`) est la section
  du format stories, encore vide : les anciennes clés de site y sont refusées (§3.2). Il définit son
  propre `LocaleSchema`, doublon de `LocaleTagSchema` (`schema/source.ts`), et `source.ts` importe
  déjà `config/validate.ts`. `STORY_FORMATS` (`types/index.ts`) est dérivé de `StoryFormatSchema`
  depuis le lot 8 de l'audit ; avant, c'était un tableau typé à la main, sans schéma zod.
- Jumeau JSON : l'aller-retour Markdown ⇄ JSON est dans `schema/convert.ts` (`markdownToSource`,
  `sourceToMarkdown`), testé par `packages/cli/src/__tests__/stories.spec.ts`. `schema/parity.ts`
  vérifie autre chose : la parité entre une variante de langue et la référence (`checkStoryParity`).
- Dépendances : ni `pagedjs`, ni `fflate`, ni `pdf-lib`, ni `pdfjs-dist` dans le lockfile. `pngjs`
  5.0.0 y est (transitif).
- Le framework de sites expose des emplacements (`Slot.Target`, dont `content-before`,
  `content-after`, `navigation-after`) et une API de plugin avec `getRoutes`. En revanche, les pages
  MDX ne sont importées que dans le module virtuel généré par le cœur
  (`packages/site/src/vite/plugin-docs.ts` construit `fileImports` pour `markdownPlugin`). Une route
  d'impression qui rend **d'autres** pages MDX n'est donc pas acquise sans patch : c'est à démontrer
  (§3.7).

### 2.2 Ce que disent les plans

- `08-formats.md` §0 annonce « tous → PDF via `@recit/paged` (P6–P8) », mais la colonne « Utilisé
  par » de `@recit/paged` ne cite que report, book et deck.
- report (§1, R2 et R4) : extraire la logique PDF de la CLI vers `@recit/paged`, **ajouter
  Paged.js**, `@page`, table des matières et en-têtes, migrer le deck. Le PDF du report y est décrit
  avec une « table des matières numérotée, en-tête courant avec le titre, pied avec la page ».
  Critère de P6 : « l'export PDF du deck passe par `@recit/paged` sans régression ».
- book (§3, B2 et B3) : réglages sous une clé `book:` du frontmatter (`trim` : A5, A4, 5x8in, 6x9in
  ; `chapters` ; `front` ; `back`), marges miroir, titres courants, numérotation romaine puis arabe,
  chapitres sur page impaire, table des matières et index numérotés, EPUB 3 maison (`fflate`,
  `epubcheck` en CI) en B3. Le report (§1) a de même une clé `report:` (`paper`, `toc`). Le deck,
  lui, a ses réglages à plat.
- toon (§2, P7) : exports « lecteur web, tranches PNG/WebP, motion ». Tranches en W3 : capture
  Playwright et découpe `sharp` dans `@recit/toon`. **Aucun PDF prévu.**
- `09-langues-et-json.md` : `settings` d'une story est « la section du format de `recit.config.ts` »
  (§2) ; `--locale` et `--all-locales` sur `build`, `export` et `render` (§5) ; chaque format livre
  son schéma avec le format (L6) ; un PDF et un EPUB par langue pour book seulement. Rien sur le
  sens de reliure RTL ni sur l'écriture verticale CJK.
- `07-domaines.md` §4 range `ExportPanel` parmi les composants de `@recit/site-kit`. `10`, `12`,
  `13` et `14` le déclarent chacun avec leurs propres types de sortie (JSON, CSV, PNG, MP4, ICS,
  Fountain…). `13-cartes.md` exige l'attribution des fonds de carte jusque dans le PDF.
- « Lancer Chromium et capturer » est revendiqué par plusieurs plans : `@recit/paged` (08 §0),
  `@recit/toon` (08 §2, W3), `@recit/cli` (`11-images.md`, l. 55 et captures
  `locator.screenshot()`), et `13-cartes.md` reste ambigu.
- `07-domaines.md` §5 : Chrome, Paged.js, Remotion et sharp tournent en CI, Vercel ne sert que du
  statique. Le texte ne dit pas comment les fichiers générés en CI arrivent sur Vercel.

### 2.3 Constats

1. **Le webtoon n'a pas de PDF.** La demande l'exige : il faut l'ajouter au périmètre de P7.
2. **« Tous → PDF » n'est pas tenu.** Il faut la liste fermée du §1.
3. **Aucun type commun.** `PdfExportOptions` vit dans la CLI, redéfinit `ThemeMode`, et n'a ni
   schéma zod ni jumeau JSON. C'est contraire aux deux règles rappelées en tête.
4. **Le signal « prêt » et le mode impression n'existent que dans le deck.** Chaque format les
   réinventerait.
5. **`print.css` mélange une base commune et des règles du deck.** Son `@page { margin: 0 }` global
   est incompatible avec les marges d'un livre.
6. **Deux mécanismes de taille de page.** Le deck impose des pixels (`preferCSSPageSize: false`), la
   pagination CSS repose sur `@page { size }`. Il faut un seul mécanisme, et des goldens du PDF du
   deck **avant** la migration (aucun n'existe).
7. **Deux conventions de réglages de format.** Le deck a ses réglages à plat (`settings`), 08 met
   ceux du report et du livre sous `report:` et `book:`. Il en faut une seule (§3.3).
8. **Plusieurs propriétaires pour « lancer Chromium ».** Le PDF, les tranches du toon, les images
   (`11-images.md`) et les cartes (`13-cartes.md`) en ont besoin : il faut un seul module (§4).
9. **Un `ExportPanel`, mais un manifeste par type de sortie.** Le composant est déjà unique
   (site-kit) ; son entrée doit l'être aussi (§3.2).
10. **Le moteur du report n'est pas tranché entre 08 et ce plan.** 08 veut Paged.js en P6 pour une
    table des matières numérotée ; Paged.js n'a plus de version stable depuis 2023 (§7). Décision
    proposée en §3.4.

## 3. Architecture proposée

### 3.1 Vue d'ensemble : une seule chaîne

```
source .md / .mdx  ──┐
                     ├─► Story (scènes) ─► build statique ─► route ?print=1 ─► Chromium ─► PDF
jumeau .json ────────┘        │                 (vue imprimable           (Playwright,
                              │                  du format)                @recit/paged)
recit.config (<format>.pdf) ─► resolvePdfOptions() (@recit/core) ───────────────┘
flags de la CLI ─────────────┘                                          └─► exports.json
```

1. La CLI charge la source (Markdown ou JSON, c'est la même `Story`), la config et les flags.
2. `resolvePdfOptions()` de `@recit/core` fusionne les couches **brutes** (entrées non parsées) puis
   valide le résultat **une seule fois** avec le schéma du format. Les défauts du schéma
   s'appliquent donc une seule fois, et une option invalide échoue **avant** de lancer Chromium,
   avec le chemin du champ.
3. Le format est construit en statique, comme pour le web, avec sa **vue imprimable** (`?print=1`).
4. `@recit/paged` (Node) sert le build, ouvre la page dans Chromium, injecte le moteur de pagination
   choisi, attend le signal « prêt » commun, puis appelle `page.pdf()`.
5. La CLI écrit le PDF et son entrée dans `exports.json`, le manifeste d'exports commun (§3.2).

Un format n'écrit que trois choses :

- son **extension de schéma** (dans `@recit/core`, jamais dans le format) ;
- sa **vue imprimable** (composant React rendu quand le mode est `print`, qui appelle
  `whenPrintable()` de `@recit/core/print`) ;
- sa **feuille d'impression** (CSS Paged Media : sauts, pages nommées propres au format).

Un format ne dépend donc pas de `@recit/paged`, une fondation non plus : seule la CLI (et les outils
de rendu Node) le font. C'est la règle de `03-packaging.md` §8 (`scope:tool`), sans exception : les
tranches du toon, les PNG des cartes et les images des stories sont capturées par la CLI à travers
le runner.

### 3.2 Les types : tout dans `@recit/core`, ajoutés phase par phase

Principe : **aucun champ de schéma avant la phase qui l'implémente.** PDF0 ne type que ce que le
deck utilise ; chaque phase ajoute ses champs et son extension (`09-langues-et-json.md` L6).

#### Primitives sans dépendance

Pour éviter un cycle (`source.ts` importe déjà `config/validate.ts`), les briques partagées quittent
`source.ts` et `validate.ts` pour un module de base sans dépendance,
`packages/core/src/config/primitives.ts`, importé par les deux :

- `LocaleTagSchema` (déplacé depuis `schema/source.ts`) ; le `LocaleSchema` en double de
  `validate.ts` est supprimé ;
- `StoryFormatSchema = z.enum(STORY_FORMATS)`, défini une seule fois. `STORY_FORMATS` devient un
  tuple `as const` et `StoryFormat` est dérivé du schéma (`z.infer`), au lieu d'être écrit à part ;
- `LengthSchema` (longueur CSS : `12mm`, `0.5in`, `10pt`, `1080px`) ;
- `PrintThemeSchema`, dérivé de `ThemeModeSchema` sans `system` (un PDF n'a qu'un schéma de
  couleurs), jamais réécrit. L'API exacte de zod/mini pour cette dérivation est à vérifier à
  l'implémentation.

#### Schéma commun et extensions par format

Fichier : `packages/core/src/config/validate.ts` (zod/mini, comme les schémas existants), types
dérivés dans `packages/core/src/config/schema.ts`. Le schéma commun ne contient que ce qui vaut pour
tous les formats ; chaque extension pose **ses défauts** (`z.prefault` sur les sous-objets, règle du
`CLAUDE.md` : `.default({})` ne lance pas le schéma interne). Il n'y a pas de table de défauts hors
schéma.

Esquisse à la fin de PDF1 (deck et report) :

```ts
import * as z from "zod/mini";
import { LengthSchema, PrintThemeSchema } from "./primitives.js";

export const PageSizeSchema = z.union([
  z.enum(["A4", "A5", "Letter"]),
  z.strictObject({ width: LengthSchema, height: LengthSchema }),
]);

export const PdfOptionsSchema = z.strictObject({
  page: z.optional(
    z.strictObject({
      size: z.optional(PageSizeSchema),
      orientation: z.optional(z.enum(["portrait", "landscape"])),
      margins: z.optional(z.union([LengthSchema, PageBoxMarginsSchema])),
    }),
  ),
  /** Title, author and date always come from the story meta, never repeated here */
  toc: z.optional(z.union([z.boolean(), z.strictObject({ depth: z.optional(positiveInt) })])),
  /** PDF bookmarks from headings (Playwright `outline`) */
  outline: z._default(z.boolean(), true),
  /** Tagged PDF (Playwright `tagged`); forced to true when outline is true (§3.4) */
  tagged: z._default(z.boolean(), true),
  theme: z.optional(PrintThemeSchema),
  /** File name pattern, e.g. "{slug}.{locale}.pdf" */
  output: z.optional(z.string()),
});

/** Deck: page size comes from aspectRatio and the design, never from page.* */
export const DeckPdfOptionsSchema = z.extend(z.omit(PdfOptionsSchema, { page: true, toc: true }), {
  notes: z._default(z.boolean(), false),
});

export const ReportPdfOptionsSchema = z.extend(PdfOptionsSchema, {
  engine: z._default(z.enum(["chromium"]), "chromium"),
  tables: z.prefault(z.strictObject({ repeatHeader: z._default(z.boolean(), true) }), {}),
});

export type DeckPdfOptions = z.output<typeof DeckPdfOptionsSchema>;
export type ReportPdfOptions = z.output<typeof ReportPdfOptionsSchema>;
export type ResolvedPdfOptions = DeckPdfOptions | ReportPdfOptions; // grows with each format
```

Les noms exacts des API zod/mini (`z._default`, `z.prefault`, `z.extend`, `z.omit`) sont à vérifier
à l'implémentation ; le principe est fixe : `z.omit` ou `z.refine` pour retirer un champ, `z.extend`
pour en ajouter, jamais de redéfinition.

Les champs suivants n'entrent dans le schéma **qu'avec leur phase** :

| Phase     | Schéma                                    | Ajoute                                                                                                                                                                                           |
| --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| PDF0      | `DeckPdfOptionsSchema`                    | `theme`, `notes`, `tagged`, `outline`, `output` (rien d'autre)                                                                                                                                   |
| PDF1      | commun + `ReportPdfOptionsSchema`         | `page.size`, `page.orientation`, `page.margins`, `toc` (sans numéros), en-tête et pied **statiques** (`{title}`, `{author}`, `{date}`, `{page}`, `{pages}` via les boîtes de marge de Chromium)  |
| PDF2      | `ToonPdfOptionsSchema`                    | `pagination: "panels" \| "continuous"`, `pageHeight: Length`, `maxBytes`                                                                                                                         |
| PDF3      | `BookPdfOptionsSchema` + moteur `pagedjs` | `engine` (défaut `pagedjs`), `page.mirror`, marges `inside`/`outside`, `page.bleed`, jetons dynamiques `{chapter}` et `{section}`, `toc.pageNumbers`, `chapterStart`, `frontMatter`, `footnotes` |
| PDF4      | `DocsPdfOptionsSchema`                    | `scope: "page" \| "section"`, `sections: string[]`                                                                                                                                               |
| plus tard | chaque format de la liste fermée          | son extension, dans sa phase                                                                                                                                                                     |

Contrôles portés par le schéma (`z.refine`), ajoutés avec les champs qu'ils concernent :

- un jeton inconnu dans un en-tête est une erreur, avec la liste des jetons valides (PDF1) ;
- `{chapter}`, `{section}`, `toc.pageNumbers` et `page.bleed` exigent `engine: pagedjs` (PDF3) ;
- `page.margins.inside`/`outside` exigent `page.mirror: true`, et `left`/`right` l'interdisent
  (PDF3).

Le `trim` de `08-formats.md` §3 **devient `pdf.page.size`** du livre : un concept, un champ. Les
valeurs `5x8in` et `6x9in` s'écrivent en valeurs littérales, `{ width: 5in, height: 8in }` ; elles
n'entrent pas dans l'énumération de `PageSizeSchema` (tranché le 24/10, question 4).

#### Où vivent les options, et dans quel ordre elles se fusionnent

Une seule convention (tranché le 24/10, question 4), celle de `09-langues-et-json.md` §2 et du code
actuel du deck : **`settings` d'une story est la section de son format dans `recit.config`**, et ses
clés s'écrivent à plat dans le frontmatter. Les options PDF sont donc la clé `pdf` de cette section,
au même niveau que les autres réglages du format.

**Collision levée (tranché le 24/10).** La clé `stories` de `recit.config` était la section du site
(`{ dir, routePrefix, decks }`, `StoriesConfigSchema` de `packages/core/src/config/validate.ts`) :
le format `stories` n'aurait pas pu y ranger ses réglages (`duration`, `autoplay`, `aspect`). La
section du site est renommée **`publish: { dir, routePrefix, decks, … }`**, et `stories:` porte les
réglages du format, comme `deck:` et `motion:`. C'est écrit dans `02-architecture.md` §5 et
`09-langues-et-json.md` §2.2. Le renommage est fait dans `validate.ts` (24/10), sans alias : une clé
de site sous `stories:` échoue à la validation, avec un message qui renvoie à `publish.<clé>`.

| Couche               | Où                                                                                            | Schéma                               |
| -------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------ |
| 1. défauts           | dans l'extension du format (`z._default`, `z.prefault`)                                       | extension du format                  |
| 2. projet            | `recit.config` → `deck.pdf`, `report.pdf`, `toon.pdf`, `book.pdf` ; pour les docs, `docs.pdf` | extension du format                  |
| 3. story             | frontmatter `pdf:` = JSON `settings.pdf`                                                      | extension du format                  |
| 4. ligne de commande | `--page-size`, `--theme`, `--notes`…                                                          | convertis vers l'extension du format |

Il n'y a **pas** de section `pdf:` à la racine de `recit.config` : une option propre à un format
(`notes` du deck, `chapterStart` du livre) se pose ainsi au niveau du projet, et config et
`settings` restent symétriques. `resolvePdfOptions(format, layers)` fusionne les entrées brutes en
profondeur de 2 à 4, puis parse une fois avec l'extension du format, qui applique la couche 1. Le
type rendu est `z.output<…>` : les consommateurs reçoivent des options complètes et ne reparsent
jamais.

Le choix de langue ne passe pas par ces options : il reste aux flags transverses `--locale` et
`--all-locales` de `09-langues-et-json.md` §5, communs à `build`, `export` et `render`.

Côté sources, chaque format reçoit `settings.pdf` typé **dans sa phase** (L6) : le deck en PDF0
(`DeckSettingsSourceSchema`), le report en PDF1, le toon en PDF2, le livre en PDF3. Avant, leurs
sources restent en `looseObject`.

#### Le manifeste d'exports, commun à tous les types de sortie

Un seul manifeste, défini une fois dans `@recit/core`, pour le PDF et pour tous les autres exports
générés en CI (EPUB, images, MP4, ICS, RSS, JSON, CSV…), afin qu'aucun plan n'invente le sien. Un
export ne vient pas toujours d'une story : un bloc (`::map` dans une page de doc, le `*.schema.json`
d'un `:::form`) ou un outil (les skills de `14-ia-scenario.md`) en produisent aussi. L'origine est
donc décrite par une union, et non par un `format` obligatoire :

```ts
const ExportOriginSchema = z.discriminatedUnion("type", [
  z.strictObject({ type: z.literal("story"), format: StoryFormatSchema }),
  z.strictObject({ type: z.literal("block"), block: DirectiveNameSchema }), // names of the B4 registry
  z.strictObject({ type: z.literal("tool"), tool: z.enum(["skills"]) }), // grows with the plans
]);

const ExportFileBase = {
  path: z.string(), // relative to the site's output dir
  origin: ExportOriginSchema,
  source: z.string(), // story path, docs route or config entry
  locale: LocaleTagSchema,
  bytes: positiveInt,
  sha256: z.string(),
};

export const ExportFileSchema = z.discriminatedUnion("kind", [
  z.strictObject({
    ...ExportFileBase,
    kind: z.literal("pdf"),
    pages: positiveInt,
    scope: z.optional(z.enum(["story", "page", "section"])),
  }),
  // epub, png, webp, gif, svg, mp4, ics, rss, json, jsonld, csv, fountain, skill:
  // added by the plan that ships them
]);
export const ExportManifestSchema = z.strictObject({
  version: z.literal(1),
  files: z.array(ExportFileSchema),
});
export type ExportFile = z.infer<typeof ExportFileSchema>;
export type ExportManifest = z.infer<typeof ExportManifestSchema>;
```

- Le fichier s'appelle `exports.json`. La liste des `kind` est réservée ici : `pdf`, `epub`, `png`,
  `webp` (tranches du toon, `08-formats.md` §2), `gif` (motion), `svg` (cartes, `13-cartes.md`),
  `mp4`, `ics`, `rss`, `json`, `jsonld` (`12-glossaire-annuaire.md` §4.2), `csv`, `fountain`
  (`14-ia-scenario.md` D10) et `skill` (`14-ia-scenario.md`). Chaque variante n'entre dans l'union
  qu'avec la phase qui la produit. Ce plan ne livre que `kind: "pdf"`, avec l'origine `story`. Un
  plan qui a besoin d'un autre `kind` l'ajoute à cette liste, jamais à côté.
- `DirectiveNameSchema` est l'énumération des noms du registre des directives du cœur (règle B4 de
  `06-roadmap.md`) ; l'origine `tool` gagne une valeur par outil qui exporte.
- `PdfExportOptions` et `PdfExportResult` de la CLI disparaissent : la variante `pdf` de
  `ExportFile` remplace `PdfExportResult`, `DeckPdfOptions` remplace `PdfExportOptions`.
- Le manifeste est publié en JSON Schema comme les autres (`schema/json-schema.ts`).

#### Contrat d'impression commun

Défini une fois dans `@recit/core`, sous-export `@recit/core/print` (code navigateur, sans
dépendance au DOM hors garde `typeof window`) :

- le mode impression se lit de `?print=1` pour tous les formats (`?mode=print` reste un alias du
  deck) ;
- `window.recitReady` (déclaré une fois dans un `declare global`) et un événement
  `recit:print-ready` ;
- `whenPrintable()` pose le signal quand : polices chargées (`document.fonts.ready`), images
  décodées, SVG de données rendus, et, avec Paged.js, fin de la pagination. Il est appelé par chaque
  vue imprimable et par la route d'impression de `@recit/site-stories`.

Le côté Node (attendre le signal depuis Playwright) vit dans `@recit/paged` (§4).

### 3.3 Le jumeau JSON des options d'export

La même option, écrite deux fois, valide deux fois avec le même schéma. Exemple du livre, dans sa
forme multi-fichiers de `08-formats.md` §3, réglages à plat (§3.2) : `book.md` porte les réglages du
livre et l'ordre des chapitres.

`book.md` :

```md
---
format: book
title: Lorem ipsum
author: Dolor Sit
lang: fr
chapters:
  - 01-lorem.md
  - 02-ipsum.md
front: [title, copyright, toc]
back: [notes, index]
pdf:
  page:
    size: A5
    mirror: true
    margins: { top: 18mm, bottom: 20mm, inside: 22mm, outside: 15mm }
  toc: { depth: 2, pageNumbers: true }
  header: { start: "{title}", end: "{chapter}" }
  footer: { center: "{page}" }
  chapterStart: right
  footnotes: page
---
```

Jumeau JSON (`book.json`) :

```json
{
  "$schema": "https://recit.dev/schema/v1/book.json",
  "kind": "story",
  "version": 1,
  "format": "book",
  "meta": { "title": "Lorem ipsum", "author": "Dolor Sit", "lang": "fr" },
  "settings": {
    "chapters": ["01-lorem.md", "02-ipsum.md"],
    "front": ["title", "copyright", "toc"],
    "back": ["notes", "index"],
    "pdf": {
      "page": {
        "size": "A5",
        "mirror": true,
        "margins": { "top": "18mm", "bottom": "20mm", "inside": "22mm", "outside": "15mm" }
      },
      "toc": { "depth": 2, "pageNumbers": true },
      "header": { "start": "{title}", "end": "{chapter}" },
      "footer": { "center": "{page}" },
      "chapterStart": "right",
      "footnotes": "page"
    }
  },
  "scenes": []
}
```

- `chapters`, `front` et `back` sont des réglages **du livre**, pas du PDF : ils décrivent sa
  structure, que lisent aussi l'édition web et l'EPUB. Le PDF s'en sert pour ses pages nommées
  (§3.6). Seule la mise en page imprimée va sous `pdf`.
- Un chapitre (`01-lorem.md`) peut aussi avoir son jumeau `01-lorem.json`. Comment les scènes des
  chapitres s'assemblent dans la `Story` relève de `08-formats.md` §3 (B1), pas de ce plan :
  `"scenes": []` ne signifie ici que « pas de scène dans le fichier d'entrée ».
- Le JSON Schema publié (`https://recit.dev/schema/v1/<format>.json`) inclut `settings.pdf`, donc
  l'éditeur complète et valide les options d'export dans le JSON comme dans le frontmatter.
- `recit convert` fait l'aller-retour sans perte : un test de `schema/convert.ts`
  (`markdownToSource` → `sourceToMarkdown`) couvre un frontmatter `pdf:` complet par format.
  `schema/parity.ts` vérifie seulement que `settings.pdf` est un attribut structurel identique d'une
  langue à l'autre, sauf `output`, qui peut varier par locale.
- `exports.json`, produit par la CLI, est lui aussi un JSON typé et publié.

### 3.4 Moteurs : Chromium partout, Paged.js pour le livre

- **Chromium (Playwright `page.pdf`)**, déjà dans la stack, moteur de tous les formats sauf le
  livre. Il gère `@page` (`size`, `margin`, `:first`, `:left`, `:right`), les pages nommées
  (propriété `page`), les sauts (`break-*`), et depuis Chrome 131 les seize boîtes de marge avec
  `counter(page)` et `counter(pages)`. Playwright 1.50, la version minimale du pair, livre Chromium
  133 : la version minimale suffit. Appel toujours explicite :
  `page.pdf({ preferCSSPageSize: true, printBackground: true, tagged, outline })`, sans
  `headerTemplate` ni `footerTemplate` (ils ne voient pas les styles de la page). `tagged` et
  `outline` existent depuis Playwright 1.42, mais sont **expérimentaux** côté Chromium, et les
  signets sont construits à partir de l'arbre balisé : `outline: true` force `tagged: true` (vérifié
  en e2e, §5).
- **Paged.js** (MIT, `pagedjs` 0.4.3), injecté comme polyfill dans la même page Playwright. On
  n'utilise jamais `pagedjs-cli`, qui embarque Puppeteer et doublerait le navigateur. Il n'apporte
  que ce que Chromium n'a pas : `string-set` (titres courants par chapitre), `target-counter()`
  (table des matières, renvois et index avec numéros de page), `float: footnote`, fond perdu et
  traits de coupe. Il n'est chargé que si `engine: pagedjs`, et son adoption passe par un spike
  go/no-go (PDF3.0).
- Écartés : Vivliostyle (AGPL-3.0, CLI qui duplique le navigateur), Prince (propriétaire),
  WeasyPrint (Python), `pdf-lib` (aucun besoin démontré).

**Décision pour le report (tranchée le 24/10, question 2) : Chromium en P6, sans numéros de page
dans la table des matières.** En P6, le PDF du report a une page de titre, une table des matières
**en liens, sans numéros**, un en-tête courant avec le titre et un pied avec la page (les deux
possibles avec les boîtes de marge de Chromium). La table des matières numérotée arrive quand le
moteur `pagedjs` existe (PDF3, P8), en option du report (`engine: pagedjs` ajouté à
`ReportPdfOptionsSchema` à ce moment-là). Cela évite d'adopter en P6 une dépendance figée depuis
2023 pour un seul numéro par ligne. Conséquence : `08-formats.md` §1 et R2 sont amendés (fait, voir
« Corrections »).

`@recit/paged` isole le moteur derrière une interface, pour pouvoir remplacer Paged.js (par sa
réécriture, ou par le natif de Chromium quand `target-counter` y arrivera) sans toucher aux formats
:

```ts
import type { Page } from "playwright-core";
import type { ResolvedPdfOptions } from "@recit/core";

export type PrintEngine = {
  name: "chromium" | "pagedjs"; // derived from the engine enum of @recit/core
  /** Inject scripts and styles before the print view renders */
  prepare: (page: Page, options: ResolvedPdfOptions) => Promise<void>;
  /** Resolve when the paginated layout is final */
  ready: (page: Page, timeoutMs: number) => Promise<void>;
};
```

Les types Playwright viennent de `playwright-core` (`import type`), jamais réécrits à la main ;
`PrintEngine` est un type interne de `@recit/paged`, pas un type partagé entre packages.

`@recit/paged` génère aussi la feuille `@page` à partir des options (`pageCss(options)`) : taille,
marges, miroir, boîtes de marge et jetons. Il l'injecte depuis Node (`addStyleTag`). Les formats
n'écrivent jamais de `@page` à la main pour ces réglages, seulement pour leurs pages nommées
propres.

**Migration du deck.** Sa vue imprimable émet `@page { size: <w>px <h>px; margin: 0 }` et passe à
`preferCSSPageSize: true`. Les goldens pris en PDF0 décident s'il y a régression. Le
`@page { margin: 0 }` global quitte `@recit/theme` pour la feuille du deck.

### 3.5 Webtoon : pages coupées entre les cases (recommandé)

Trois formes possibles :

| Forme                                                 | Pour                        | Contre                                                                                                                                                                                                                                            |
| ----------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A. une seule page très haute                          | fidèle au ruban             | la limite d'implémentation d'Acrobat (ISO 32000-1, annexe C), respectée par la plupart des lecteurs, est de 14 400 unités (200 pouces, 19 200 px CSS à 96 dpi) ; un épisode la dépasse vite ; comportement de Chromium et des lecteurs non mesuré |
| **B. pages de hauteur fixe, coupées entre les cases** | lisible partout, imprimable | une marge vide en bas de page quand la case suivante ne tient pas                                                                                                                                                                                 |
| C. mise en page BD (planches)                         | objet imprimé classique     | c'est un autre format (mise en page de cases sur une grille), hors de l'idée du webtoon                                                                                                                                                           |

**Tranché (24/10, question 1) : B**, `pagination: "panels"` par défaut. Pas de CBZ.

- Largeur de page = largeur du préréglage de plateforme du toon (préréglages de W3, réutilisés).
  Hauteur = `pageHeight`, **indépendante des tranches** : les tranches de plateforme (W3) sont
  coupées à hauteur fixe sans égard aux cases, alors que le PDF coupe toujours entre deux cases. Les
  deux découpages ne sont pas les mêmes, et c'est voulu.
- Chaque case porte `break-inside: avoid` : une coupe tombe toujours entre deux cases, jamais dans
  une bulle.
- Une case plus haute que `pageHeight` (case verticale pleine page, cases sans gouttière) est
  courante : elle déclenche un **avertissement activé par défaut**, et la case reçoit sa propre
  page, plus haute. Jamais d'erreur, jamais de coupe dans une case.
- L'espace restant en bas de page prend la couleur de gouttière du toon.
- La géométrie des cases est mesurée **avant** `page.pdf()` (positions dans le DOM), avec le module
  partagé « lancer Chromium, attendre prêt, mesurer » de `@recit/paged` (§4), que les tranches de W3
  utilisent aussi.
- **Poids.** Chromium (Skia) recopie le JPEG tel quel mais réencode WebP, AVIF et PNG en Flate : un
  épisode de cases WebP peut produire un PDF très lourd. La vue imprimable utilise donc des
  variantes JPEG des cases, et `maxBytes` fixe un budget de poids par PDF (avertissement, erreur
  avec `--strict`), vérifié en e2e sur `examples/toon-episode`.
- `pagination: "continuous"` (forme A) n'est promis qu'après un spike mesuré (PDF2.3) : une page de
  20 000 px puis de 50 000 px, ouvertes dans pdf.js et Acrobat. Au-delà de ce qui est mesuré :
  erreur, hauteur mesurée, jamais devinée.
- `toon.direction: rtl` (manga) ne change pas l'ordre vertical des cases. Il change l'ordre de
  lecture des bulles dans une case, ce que la vue imprimable respecte déjà. Le réglage du lecteur
  PDF (`/Direction R2L`) demanderait un post-traitement : hors de la première tranche.
- Chaque case porte un `alt` (description) dans le schéma de `toon` ; sans lui, le PDF balisé est
  muet. Un `alt` manquant est un **avertissement**, pas une erreur (tranché le 24/10, question 6).
  EPUB et CBZ pour toon : hors périmètre (le défilement vertical continu n'est pas normalisé en
  EPUB).

### 3.6 Book : chapitres, table des matières, notes, renvois

Le livre est le seul format qui exige Paged.js (`engine: pagedjs` par défaut), sous réserve du spike
PDF3.0.

- **Chapitres.** Un chapitre de `chapters` = une section avec `break-before: right` si
  `chapterStart: right` (page blanche insérée au besoin), page nommée `chapter` dont la première
  page n'a pas de titre courant (`@page chapter:first`).
- **Pré et post-texte.** Les réglages `front` et `back` du livre (§3.3) donnent des pages nommées
  (`front`, `back`). `frontMatter.numbering: roman` donne `counter(page, lower-roman)` sur `front`
  et remet le compteur à 1 au premier chapitre.
- **Titres courants.** `{title}` et `{chapter}` via `string-set` sur le titre de chapitre. Par
  défaut : titre du livre à gauche, chapitre à droite (`@page :left` et `:right`), inversé en RTL.
  Paged.js écrit ces titres comme du texte réel dans chaque page : ils doivent être marqués
  `aria-hidden` (artefacts) pour ne pas être lus comme du contenu (vérifié en PDF3.6).
- **Table des matières.** Générée depuis la Story (titres jusqu'à `toc.depth`), en liens, avec
  `target-counter(attr(href), page)`. Les signets PDF (`outline`) viennent des mêmes titres.
- **Notes.** La syntaxe GFM `[^1]` existe déjà dans la Story. `footnotes: page` → `float: footnote`
  (Paged.js), `chapter-end` et `book-end` → liste de notes en fin de chapitre ou de livre, sans
  Paged.js.
- **Renvois et index.** `:index[terme]` est déjà prévu en B2 (`08-formats.md` §3) ; une directive de
  renvoi (« page N » en PDF, lien en web) est à fixer en B2 avec `12-glossaire-annuaire.md`. Ce plan
  ne fixe aucune syntaxe : il fournit seulement `target-counter` via le moteur `pagedjs`. Une cible
  absente est une erreur de build.
- **HTML intermédiaire.** `@recit/book` produit une fois le HTML du livre (la Story rendue en hast,
  `hast-util-to-html` est déjà dans la stack). La chaîne PDF le consomme. L'EPUB (sérialisation
  XHTML, `nav.xhtml`, OPF, zip, `epubcheck`) reste entièrement en B3, hors de ce plan.
- **Impression professionnelle.** `page.bleed` et les traits de coupe passent par Paged.js. CMJN,
  OutputIntent et PDF/X : **hors périmètre** (Chromium ne produit que du RVB).

### 3.7 Pages et sections de doc (Le framework de sites), en option

Le PDF des docs est **optionnel** et vient après le livre (PDF4). Il n'est pas un critère de P6.

- **Ce qu'on exporte.** `scope: "page"` : une page de doc. `scope: "section"` : une section de la
  sidebar, ses pages dans l'ordre de navigation, avec une table des matières. Les options sont la
  section `docs.pdf` de `recit.config` (couche 2), typées par `DocsPdfOptionsSchema`. Si elles
  doivent aussi passer par les options du plugin `@recit/site-stories`, ce sont les mêmes données et
  le même schéma, pas une cinquième couche.
- **Rendu : à démontrer avant de promettre.** `scope: "page"` est simple : on imprime la route
  existante avec la feuille d'impression. `scope: "section"` demande une route d'impression qui rend
  **plusieurs** pages MDX, alors que le cœur ne les importe que dans son module virtuel (§2.1). Le
  spike PDF4.0 doit prouver qu'un plugin Vite de `@recit/site-stories` (son propre
  `import.meta.glob` et l'ordre de navigation) suffit sans patch du cœur. Sinon : changement de
  `packages/site`, ou périmètre limité à `scope: "page"`.
- **Ordre de build.** `recit export --to pdf --site <dir>` : (1) build du site ; (2) service du
  dossier de sortie et impression des routes listées ; (3) écriture des PDF et de `exports.json`
  **directement dans le dossier de sortie** (`dist/`), sans second build. Le bouton lit
  `exports.json` à l'exécution (`fetch`) : pas de fichier ou pas d'entrée, pas de bouton, donc
  jamais de lien mort sur une prévisualisation.
- **Le bouton.** Un composant de `@recit/site-stories`, dans le slot `content-before` (page) et
  `navigation-after` (section). Les composants interactifs (playground, onglets de code) ont une
  variante imprimable (tous les onglets dépliés, playground masqué avec un lien).

**Où se fait le rendu, pour tous les formats.** En CI uniquement (`07-domaines.md` §5). La façon
dont les fichiers générés en CI arrivent sur Vercel (déploiement « prebuilt » depuis GitHub Actions,
ou stockage statique séparé) n'est pas une tâche de ce plan : c'est une décision de `07-domaines.md`
§5, qui concerne aussi les MP4 de motion et les tranches du toon. Elle est un prérequis pour qu'un
PDF soit téléchargeable sur book.recit.dev ou webtoon.recit.dev.

**Le panneau d'export.** `ExportPanel` reste un composant de `@recit/site-kit` (`07-domaines.md`
§4), comme les autres composants de site de format. Il lit `exports.json` et affiche les fichiers de
chaque `kind`. Ce plan ne fournit que les entrées `kind: "pdf"` du manifeste.

### 3.8 Langues : un PDF par locale, RTL et CJK

- `--all-locales` produit un PDF par locale de `i18n.locales`, nommé par `output`
  (`{slug}.{locale}.pdf` par défaut). Chaque fichier est une entrée du manifeste avec sa `locale`.
  La table des matières, l'index et les jetons (`{date}`) sont localisés (`Intl`).
- `<html lang dir>` est posé depuis la locale (`dir` déduit comme dans `09-langues-et-json.md`). Les
  feuilles utilisent des propriétés logiques. En RTL, `@page :left` et `:right` s'échangent pour les
  titres courants et les marges miroir. Le comportement de Chromium sur la progression des pages en
  RTL est à vérifier par un golden.
- **Polices.** Chromium embarque des sous-ensembles des polices utilisées. Le lint de couverture des
  glyphes de `09-langues-et-json.md` devient **bloquant** pour un PDF (un carré vide figé dans un
  fichier ne se corrige plus).
- **Plus tard, sans champ de schéma tant qu'aucune phase ne les implémente** : contrôle `fsType`
  (licence d'embarquement des polices), `fonts.fallback` par locale, seuil `images.minDpi`.
- Hors périmètre de la première version : écriture verticale CJK (`writing-mode`) et sens de reliure
  RTL du lecteur PDF (`/Direction`).

### 3.9 La ligne de commande

| Commande                             | Effet                                                                                       | Phase |
| ------------------------------------ | ------------------------------------------------------------------------------------------- | ----- |
| `recit export --to pdf <file>`       | aiguille sur `format:` de la source (Markdown ou JSON) ; erreur si le format n'a pas de PDF | PDF1  |
| `recit export --to pdf --site <dir>` | pages et sections de doc listées dans `docs.pdf`                                            | PDF4  |

`recit export [file]` et `--html` restent tels quels ; `export epub` (B3) et `export images` (W3,
`11-images.md`) appartiennent à leurs plans. Les flags sont convertis vers l'extension du format
puis passent par `resolvePdfOptions()`, comme le reste.

## 4. Packages et responsabilités

| Package                                                     | Rôle dans l'export PDF                                                                                                                                                                                                                                                                                                                                             | Dépend de                                                                                                                |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `@recit/core`                                               | **tous les types** : primitives, `PdfOptionsSchema` et ses extensions, `resolvePdfOptions()`, `ExportManifestSchema` ; sous-export `print` (navigateur) : `?print=1`, `recitReady`, événement, `whenPrintable()` ; JSON Schemas publiés                                                                                                                            | zod (existant)                                                                                                           |
| `@recit/paged` (`packages/tools/paged`, `scope:tool`), Node | **la chaîne** : sous-export `@recit/paged/runner`, seul module « lancer Chromium, servir un build, attendre prêt, mesurer, imprimer ou capturer », utilisé par le PDF, les tranches du toon (W3), les images (`11-images.md`) et les cartes (`13-cartes.md`) ; `PrintEngine`, `pageCss(options)` ; injection de Paged.js depuis Node. Importé par la CLI seulement | `@recit/core` ; Playwright en pair optionnelle ; `pagedjs` chargé à la demande                                           |
| `@recit/theme`                                              | `print.css` réduit à une base commune (animations coupées, `print-color-adjust`, `pre-wrap`), commentaires en anglais                                                                                                                                                                                                                                              | —                                                                                                                        |
| Formats (`@recit/deck`, `report`, `toon`, `book`)           | vue imprimable + feuille d'impression du format (pages nommées, sauts). Les règles d'impression du deck quittent `@recit/theme` pour `@recit/deck`. `@recit/book` produit le HTML intermédiaire, `@recit/toon` la découpe des tranches à partir de la géométrie mesurée. Aucun type d'export défini ici                                                            | `@recit/core`, jamais `@recit/paged` : pour W3, la CLI capture par `@recit/paged/runner` puis appelle la découpe du toon |
| `@recit/cli`                                                | `export --to pdf`, `export images` (W3), flags → schéma, aiguillage sur `format:`, écriture du PDF et du manifeste. Seul package qui importe `@recit/paged`. Plus de lanceur de Chromium ni de types Playwright locaux                                                                                                                                             | `@recit/core`, `@recit/paged`, formats                                                                                   |
| `@recit/site-stories` (PDF4, option)                        | bouton dans les slots, variantes imprimables des composants MDX, route d'impression si le spike la valide ; appelle `whenPrintable()`                                                                                                                                                                                                                              | `@recit/site` (seul package autorisé), `@recit/core`                                                                     |
| `@recit/site-kit` (privé)                                   | `ExportPanel` unique, lit `exports.json` (tous les `kind`)                                                                                                                                                                                                                                                                                                         | `@recit/core`                                                                                                            |

Pourquoi `@recit/paged/runner` plutôt qu'un nouveau package : le module existe déjà en germe dans la
CLI, il porte déjà le pair Playwright, et un package de plus pour un seul module contredirait la
règle « une seule stack ». Tranché le 24/10 (question 3) : le lancement de Chromium reste dans
`@recit/paged/runner`, malgré un nom qui sert mal les captures d'images.

`@recit/paged` est **Node uniquement** : aucun format ne l'importe dans un bundle client, donc ni
Playwright ni `node:http` ne peuvent y entrer. Le code navigateur (contrat, `whenPrintable()`) est
dans `@recit/core/print`.

Dépendances :

- **existantes** : Playwright (passe de pair optionnelle de `@recit/cli` à pair optionnelle de
  `@recit/paged`, la CLI la déclarant toujours ; `playwright-core` pour les types) ;
  `hast-util-to-html`.
- **nouvelles, justifiées** : `pagedjs` (MIT), pour book uniquement, après le spike PDF3.0.
- **test seulement** : `pdfjs-dist` (Apache-2.0) en dépendance de dev de `e2e/`, exécuté dans le
  Chromium de Playwright, plutôt que poppler, outil système hors de la stack JS. Nécessaire dès PDF0
  (goldens).
- **refusées** : `pdf-lib` (pas de besoin démontré), Vivliostyle, Puppeteer et `pagedjs-cli`
  (deuxième navigateur).
- **CI seulement, jamais dans un package** : éventuellement veraPDF (question ouverte, Java).
  `epubcheck` relève de B3.

Chaque changement de package publié vient avec son plan de version (`nx release plan`). Aucun patch
de `packages/site` n'est prévu pour PDF0 à PDF3 ; pour PDF4, le spike PDF4.0 décide.

## 5. Tests

**Unitaires (vitest), `@recit/core` :**

- parse de chaque extension, défauts (`tagged` et `outline` à `true`, moteur par format), effet de
  `z.prefault` ; `DeckPdfOptionsSchema` refuse `page.size` (test de l'`omit`) ;
- refus explicites, avec leur phase : jeton inconnu (PDF1), `{chapter}` avec `engine: chromium`,
  `bleed` sans Paged.js, `inside` sans `mirror` (PDF3) ;
- ordre de fusion de `resolvePdfOptions()` (config < story < CLI), parse unique ;
- JSON Schema généré : `settings.pdf` présent pour chaque format qui exporte en PDF dès sa phase,
  absent pour `motion` et `stories` ;
- aller-retour `markdownToSource` → `sourceToMarkdown` (`schema/convert.ts`) avec un frontmatter
  `pdf:` complet ; `parity.ts` : `settings.pdf` identique entre langues, sauf `output` ;
- `ExportManifestSchema` : aller-retour d'un manifeste produit par la CLI.

**Unitaires, `@recit/paged` :** `pageCss(options)` en snapshot (taille, marges, boîtes de marge,
RTL) ; sélection du moteur.

**e2e (Playwright, fixtures `examples/*`, Lorem Ipsum) :** chaque PDF exporté est ouvert avec
`pdfjs-dist` dans le même Chromium, puis :

- **nombre et taille des pages** égaux au golden de la fixture (deck : nombre de slides, plus les
  pages de notes) ;
- **signets** : titres et ordre égaux aux titres de la Story, avec `outline: true` et `tagged`
  `false` puis `true` (pour vérifier que `outline` force `tagged`) ;
- **balisage** : PDF marqué et arbre de structure non vide, `Lang` égal à la locale ; pour le livre,
  les titres courants et numéros de Paged.js absents de l'arbre (artefacts) ;
- **polices** : aucune police non embarquée ;
- **métadonnées** : titre et auteur égaux à `meta` ;
- **liens** : chaque entrée de la table des matières pointe vers la page attendue ; pour le livre,
  le numéro imprimé correspond à cette page ;
- **régression visuelle** : chaque page rendue en PNG et comparée par `toHaveScreenshot`, comme les
  captures existantes (`e2e/__screenshots__`) ;
- **toon** : aucune case coupée (géométrie vérifiée dans le DOM avant impression), largeur égale au
  préréglage, poids sous `maxBytes` ;
- **docs** (PDF4) : le bouton n'apparaît que si `exports.json` a une entrée, et son lien répond ;
- **i18n** : un fichier par locale avec `--all-locales`, et un golden RTL.

**CI :** version de Playwright épinglée, pour que les goldens ne bougent pas avec Chromium.

## 6. Phases

Les phases s'appellent PDF0 à PDF5. Elles ne créent pas de nouvelle phase de roadmap : elles se
logent dans P6, P7 et P8, sauf PDF4 (docs, optionnel) et PDF5.

### PDF0 — Le deck, typé (S), au début de P6

- [ ] PDF0.1 — `config/primitives.ts` : `LocaleTagSchema` déplacé, `LocaleSchema` en double
      supprimé, `StoryFormatSchema` et `StoryFormat` dérivés de `STORY_FORMATS`, `LengthSchema`,
      `PrintThemeSchema` dérivé de `ThemeModeSchema`.
- [ ] PDF0.2 — `DeckPdfOptionsSchema` (`theme`, `notes`, `tagged`, `outline`, `output`),
      `resolvePdfOptions()`, `ExportManifestSchema` avec la seule variante `pdf`, types dérivés.
- [ ] PDF0.3 — `deck.pdf` dans `DeckConfigSchema`, `settings.pdf` dans `DeckSettingsSourceSchema` ;
      JSON Schemas régénérés ; test d'aller-retour de `convert.ts`.
- [ ] PDF0.4a — `pdfjs-dist` en dépendance de dev de `e2e/` (question 7 tranchée avant cette tâche),
      lecture du nombre, de la taille et du rendu PNG des pages d'un PDF.
- [ ] PDF0.4 — goldens du PDF actuel du deck (pages en PNG, nombre et taille des pages) **avant**
      toute migration.
- [ ] PDF0.5 — `PdfExportOptions` et `PdfExportResult` de la CLI remplacés par `DeckPdfOptions` et
      `ExportFile` ; `tagged: true` et `outline: true` passés à l'appel `page.pdf` existant.

**Sortie :** le PDF du deck est balisé, a des signets, et ses options sont typées par `@recit/core`,
identiques dans le frontmatter et dans le JSON.

### PDF1 — La chaîne dans `@recit/paged`, deck et report (M), P6 (R2, R4)

- [ ] PDF1.0 — déplacer le stub `packages/formats/paged` vers `packages/tools/paged` (tag
      `scope:tool`) ; nouvelle description du `package.json` : « Node print chain used by the CLI »
      ; mettre à jour `NOTICE.md` (« What comes from where ») et `03-packaging.md` §2.
- [ ] PDF1.1 — `loadPlaywright`, `serveStatic` et l'attente « prêt » quittent la CLI pour
      `@recit/paged/runner` ; module partagé de rendu, de mesure et de capture.
- [ ] PDF1.2 — contrat d'impression et `whenPrintable()` dans `@recit/core/print`, le deck s'y
      branche sans changement visible.
- [ ] PDF1.3 — interface `PrintEngine`, moteur `chromium`, `pageCss(options)` ; `page.pdf` avec
      `preferCSSPageSize: true`, `tagged`, `outline` ; repli `channel: "chrome"` retiré, ou
      vérification `browser.version() >= 131` avec une erreur claire quand les boîtes de marge sont
      requises ; e2e `outline` avec `tagged` faux et vrai.
- [ ] PDF1.4 — `print.css` découpé : base commune dans `@recit/theme`, règles du deck dans
      `@recit/deck`, commentaires en anglais.
- [ ] PDF1.5 — migration du deck sur `@recit/paged`, comparée aux goldens de PDF0.
- [ ] PDF1.6 — schéma commun (`page`, `toc` sans numéros, en-têtes statiques) et
      `ReportPdfOptionsSchema` ; `report.pdf` en config et `settings.pdf` du report.
- [ ] PDF1.7 — vue imprimable de `report` : page de titre, table des matières en liens sans numéros,
      en-tête avec le titre et pied avec la page (boîtes de marge), tableaux à en-tête répété.
- [ ] PDF1.8 — CLI : `recit export --to pdf <file>` avec aiguillage sur `format:`, écriture de
      `exports.json`.
- [ ] PDF1.9 — e2e : pages, signets, balisage, polices, métadonnées, PNG, pour deck et report.

**Sortie :** c'est le critère de P6 déjà écrit (« l'export PDF du deck passe par `@recit/paged` sans
régression »), plus un PDF de `examples/report-quarterly` balisé, avec signets, vérifié en e2e.

### PDF2 — Webtoon (M), dans P7

- [ ] PDF2.1 — `ToonPdfOptionsSchema` (`pagination`, `pageHeight`, `maxBytes`), `toon.pdf` en config
      et `settings.pdf` du toon ; `alt` des cases, un `alt` manquant donne un avertissement.
- [ ] PDF2.2 — vue imprimable : pages de la largeur du préréglage (W3), `break-inside: avoid` par
      case, `pageHeight` indépendante des tranches, case trop haute sur sa propre page avec un
      avertissement, variantes JPEG des cases, mesure par le module partagé.
- [ ] PDF2.3 — spike mesuré de `pagination: continuous` (pages de 20 000 et 50 000 px dans pdf.js et
      Acrobat) avant de le promettre.
- [ ] PDF2.4 — e2e : aucune case coupée, PNG des pages, budget de poids sur `examples/toon-episode`,
      deux langues.

**Sortie :** `examples/toon-episode` produit un PDF par langue, sans case coupée, sous son budget de
poids, listé dans `exports.json`. À ajouter aux critères de P7.

### PDF3 — Book (L), dans P8 (B2)

- [ ] PDF3.0 — spike go/no-go Paged.js sur `examples/book-novella` : garder 0.4.3, adopter la
      0.5.0-beta, ou attendre le natif de Chromium. Décision écrite avant la suite.
- [ ] PDF3.1 — `BookPdfOptionsSchema`, `book.pdf` en config et `settings.pdf` du livre ; réglages du
      livre à plat (`trim` remplacé par `pdf.page.size` dans `08-formats.md`).
- [ ] PDF3.2 — moteur `pagedjs` derrière `PrintEngine`, ajout de `pagedjs` selon PDF3.0 ; option
      `engine: pagedjs` ouverte au report (table des matières numérotée).
- [ ] PDF3.3 — chapitres sur page impaire, pages nommées `front`, `chapter`, `back`, numérotation
      romaine puis arabe, marges miroir.
- [ ] PDF3.4 — titres courants, table des matières numérotée, notes de bas de page, `target-counter`
      pour l'index et les renvois de B2.
- [ ] PDF3.5 — la chaîne PDF consomme le HTML intermédiaire produit par `@recit/book` (l'EPUB reste
      en B3).
- [ ] PDF3.6 — un PDF par langue, golden RTL, lint de glyphes bloquant ; contenu des boîtes de marge
      de Paged.js marqué comme artefact.
- [ ] PDF3.7 — e2e : numéros de la table des matières égaux aux pages réelles, notes, signets.

**Sortie :** c'est le critère de P8 déjà écrit (`examples/book-novella` produit un PDF imprimable,
un EPUB valide et une édition web), précisé pour le PDF : balisé, signets, numéros de page de la
table des matières vérifiés, un fichier par langue, listé dans `exports.json`.

### PDF4 — Pages et sections de doc (M), optionnel, après P8

- [ ] PDF4.0 — spike : une route d'impression charge les MDX d'une section par un plugin Vite de
      `@recit/site-stories` (glob propre et ordre de navigation), sans patch du cœur. Sinon,
      changement de `packages/site` ou `scope: "page"` seulement.
- [ ] PDF4.1 — `DocsPdfOptionsSchema`, `docs.pdf` ; `recit export --to pdf --site` (build,
      impression, écriture dans `dist/`).
- [ ] PDF4.2 — variantes imprimables des composants MDX ; bouton dans les slots, piloté par
      `exports.json` lu à l'exécution.

**Sortie :** une page (et, si le spike le permet, une section) de docs.recit.dev se télécharge en
PDF, généré en CI, sans Chromium sur Vercel.

### PDF5 — Les autres formats, dans leur phase

Chaque format de la liste fermée du §1 ajoute son extension de schéma et sa vue imprimable dans sa
propre phase, par le même chemin, sans chaîne nouvelle.

### Corrections à apporter aux autres plans (par l'intégrateur)

Appliquées le 24/10/2026 à `03-packaging.md`, `06-roadmap.md`, `07-domaines.md`, `08-formats.md` et
aux plans 10 à 14. Les corrections de `02-architecture.md`, `09-langues-et-json.md` et `NOTICE.md`
attendent la fin de P3 (fichiers en cours de modification). La décision de `07-domaines.md` §5
(Vercel) reste ouverte (question 8).

- `08-formats.md` §0 : ajouter « PDF » aux exports de `toon` ; colonne « Utilisé par » de
  `@recit/paged` : deck, report, toon, book, puis les formats suivants ; remplacer « tous → PDF »
  par un renvoi à la liste fermée de ce plan.
- `08-formats.md` §1 et R2 : en P6, le PDF du report a une table des matières **sans numéros** ;
  Paged.js et la table numérotée arrivent en P8 (PDF3), en option. Réglages du report à plat
  (`paper` → `pdf.page.size`, `toc` → `pdf.toc`), sans clé `report:`.
- `08-formats.md` §2 et W3 : la capture des tranches passe par `@recit/paged/runner` ; le PDF du
  toon a son propre découpage, distinct des tranches.
- `08-formats.md` §3, B2 et B3 : réglages du livre à plat, sans clé `book:` (`chapters`, `front`,
  `back` dans `settings`) ; `trim` → `pdf.page.size` ; le HTML intermédiaire est produit par
  `@recit/book` et partagé avec la chaîne PDF ; l'EPUB reste en B3.
- `03-packaging.md` §6 : `@recit/paged` est Node uniquement, avec le sous-export `runner` ;
  `@recit/core` gagne le sous-export `print`.
- `06-roadmap.md` : critères de sortie de P6 (PDF0, PDF1), P7 (PDF2) et P8 (PDF3) ; PDF4 optionnel
  après P8.
- `07-domaines.md` §4 : `ExportPanel` lit `exports.json` (`ExportManifestSchema` de `@recit/core`).
  §5 : décision sur la façon dont les fichiers générés en CI arrivent sur Vercel (PDF, EPUB, MP4,
  tranches).
- `10-formulaires.md`, `12-glossaire-annuaire.md`, `13-cartes.md` et `14-ia-scenario.md` :
  `ExportPanel` renvoie au composant de `@recit/site-kit`, et leurs sorties (JSON, CSV, PNG, MP4,
  ICS, Fountain…) deviennent des variantes `kind` de `ExportManifestSchema`, ajoutées dans leur
  phase.
- `11-images.md` (l. 55 et captures `locator.screenshot()`) et `13-cartes.md` (PNG) : la capture
  passe par `@recit/paged/runner`, qui porte le pair Playwright.
- `09-langues-et-json.md` : rappeler que `--locale` et `--all-locales` pilotent aussi
  `export --to pdf`.
- Description de `@recit/paged` (`package.json`) : « Shared print pipeline and PDF output of every
  Récit format that prints ».

## 7. Risques

| Risque                                                                                                                             | Parade                                                                                                                                   |
| ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Paged.js figé : 0.4.3 (juillet 2023), 0.5.0-beta.2 (octobre 2024), aucune version stable depuis ; réécriture en cours              | interface `PrintEngine` ; Paged.js réservé à book (et en option au report) ; spike go/no-go PDF3.0 ; Chromium seul pour tous les autres  |
| Paged.js lent ou fragile sur les longs documents (`08-formats.md` §9)                                                              | goldens sur `examples/book-novella` ; délai d'attente réglable ; mesure du temps en CI                                                   |
| Le rendu change avec la version de Chromium, les goldens cassent                                                                   | Playwright épinglé en CI ; mise à jour des goldens dans un commit dédié                                                                  |
| Migration du deck vers `preferCSSPageSize: true` avec des différences au pixel près                                                | goldens pris en PDF0, avec `pdfjs-dist`, avant la migration                                                                              |
| `tagged` et `outline` expérimentaux dans Chromium                                                                                  | e2e dédiés ; `outline` force `tagged`                                                                                                    |
| PDF balisé de mauvaise qualité (dépend du HTML), et plus faible pour le livre (titres courants et paragraphes coupés par Paged.js) | HTML sémantique, `alt` recommandé, ordre DOM = ordre de lecture ; artefacts marqués ; qualité de balisage moindre acceptée pour le livre |
| PDF du webtoon trop lourd (WebP, AVIF, PNG réencodés en Flate)                                                                     | variantes JPEG des cases ; `maxBytes` ; e2e de poids                                                                                     |
| Case de webtoon plus haute qu'une page                                                                                             | page propre à la case, avertissement ; jamais de coupe                                                                                   |
| Route d'impression des docs impossible sans patch du cœur                                                                          | spike PDF4.0 ; PDF4 optionnel ; repli sur `scope: "page"`                                                                                |
| Fichiers générés en CI absents sur Vercel, boutons morts                                                                           | bouton et panneau pilotés par `exports.json` ; décision de déploiement dans `07-domaines.md` §5                                          |
| Dérive vers l'impression professionnelle (CMJN, PDF/X)                                                                             | hors périmètre écrit ; bleed seulement avec Paged.js                                                                                     |
| Chaque format redéfinit ses options, son manifeste ou son signal « prêt »                                                          | schémas, manifeste et contrat dans `@recit/core` ; revue : aucun `type` d'export hors de `@recit/core`                                   |
| Temps de CI (un PDF par locale et par format)                                                                                      | `nx affected` ; PDF des sites seulement sur la branche principale et les releases                                                        |

## 8. Questions ouvertes

Les questions 1 à 6 ont été tranchées le 24/10/2026, sans l'utilisateur et à sa demande (décisions
réversibles). Les questions 7 à 9, et veraPDF dans la question 6, restent ouvertes.

1. ~~**Webtoon** : la forme B est-elle validée ? Faut-il un jour un CBZ ?~~ Tranché (24/10) : forme
   B (pages de hauteur fixe coupées entre les cases, case trop haute sur sa propre page) ; pas de
   CBZ.
2. ~~**Report** : table des matières sans numéros en P6, numérotée en P8 avec Paged.js ?~~ Tranché
   (24/10) : oui, sans numéros en P6, numérotée en P8 (Paged.js).
3. ~~**Nom du runner** : garder `@recit/paged/runner`, ou un package nommé pour ce rôle ?~~ Tranché
   (24/10) : le lancement de Chromium reste dans `@recit/paged/runner`.
4. ~~**Réglages à plat** : une seule convention (`settings` = section du format), sans clés `book:`
   ni `report:` ? Les tailles `5x8in` et `6x9in` dans l'énumération ?~~ Tranché (24/10) : une seule
   convention de réglages à plat ; les tailles s'écrivent en valeurs littérales, hors de
   l'énumération.
5. ~~**Liste fermée et roadmap** : un PDF pour `stories` ? Le PDF des docs optionnel, après P8 ?~~
   Tranché (24/10) : pas de PDF pour `stories` ; PDF des pages de doc optionnel, après P8.
6. ~~**Accessibilité** : le PDF balisé est-il une obligation, et un `alt` manquant fait-il échouer
   le build ?~~ Tranché (24/10) : PDF balisé activé par défaut ; un `alt` manquant est un
   avertissement. Reste ouvert : veraPDF en CI (Java), oui ou non ?
7. **Lecteur de PDF pour les tests** : `pdfjs-dist` en dépendance de dev de `e2e/` (recommandé, à
   trancher avant PDF0), ou poppler en CI ?
8. **Vercel** : déploiement « prebuilt » depuis GitHub Actions pour tous les sites qui servent des
   fichiers générés (PDF, EPUB, MP4, tranches), ou autre stockage statique ? (Décision à porter dans
   `07-domaines.md` §5.)
9. **Docs** (si PDF4 est gardé) : quelles sections de docs.recit.dev méritent un PDF ? Le bouton
   va-t-il dans `content-before` ou dans un menu d'actions de page ?
