Formats

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.

FormatPDF ?Forme du PDFPhase
deckouiune page par slide (et une page de notes en option), taille du designP6 (PDF0, PDF1)
reportouiA4 ou Letter, page de titre, table des matières, tableaux paginésP6 (PDF1)
toonouipages de hauteur fixe, coupées entre les cases (§3.5)P7 (PDF2)
bookouilivre paginé : chapitres, table des matières, notes, renvois, index (§3.6)P8 (PDF3)
docsoptionune page, ou une section de la sidebar (§3.7)après P8, optionnel (PDF4)
roadmap, calendarouifrise, mois ou agenda (spec dans leur phase)P9–P10
glossary, directoryouiliste A–Z, annexe (12-glossaire-annuaire.md, DI5.5)phase de 12-glossaire-annuaire.md
atlas et bloc ::mapouiSVG net à l'impression (13-cartes.md)bloc : lot P6 (GE1 à GE8) ; atlas : P15
formouiversion imprimable avec QR code et URL (10-formulaires.md, FO6.1)plus tard (FO6.1), après P13
motionnonvidéo : recit render—
storiesnonimages 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

Code
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) :

TypeScriptTypeScript
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 :

PhaseSchémaAjoute
PDF0DeckPdfOptionsSchematheme, notes, tagged, outline, output (rien d'autre)
PDF1commun + ReportPdfOptionsSchemapage.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)
PDF2ToonPdfOptionsSchemapagination: "panels" | "continuous", pageHeight: Length, maxBytes
PDF3BookPdfOptionsSchema + moteur pagedjsengine (défaut pagedjs), page.mirror, marges inside/outside, page.bleed, jetons dynamiques {chapter} et {section}, toc.pageNumbers, chapterStart, frontMatter, footnotes
PDF4DocsPdfOptionsSchemascope: "page" | "section", sections: string[]
plus tardchaque format de la liste ferméeson 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é>.

CoucheOùSchéma
1. défautsdans l'extension du format (z._default, z.prefault)extension du format
2. projetrecit.config → deck.pdf, report.pdf, toon.pdf, book.pdf ; pour les docs, docs.pdfextension du format
3. storyfrontmatter pdf: = JSON settings.pdfextension 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 :

TypeScriptTypeScript
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 :

MarkdownMarkdown
--- 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) :

JSONJSON
{ "$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 :

TypeScriptTypeScript
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 :

FormePourContre
A. une seule page très hautefidèle au rubanla 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 caseslisible partout, imprimableune marge vide en bas de page quand la case suivante ne tient pas
C. mise en page BD (planches)objet imprimé classiquec'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

CommandeEffetPhase
recit export --to pdf <file>aiguille sur format: de la source (Markdown ou JSON) ; erreur si le format n'a pas de PDFPDF1
recit export --to pdf --site <dir>pages et sections de doc listées dans docs.pdfPDF4

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

PackageRôle dans l'export PDFDépend de
@recit/coretous les types : primitives, PdfOptionsSchema et ses extensions, resolvePdfOptions(), ExportManifestSchema ; sous-export print (navigateur) : ?print=1, recitReady, événement, whenPrintable() ; JSON Schemas publiészod (existant)
@recit/paged (packages/tools/paged, scope:tool), Nodela 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/themeprint.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/cliexport --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

RisqueParade
Paged.js figé : 0.4.3 (juillet 2023), 0.5.0-beta.2 (octobre 2024), aucune version stable depuis ; réécriture en coursinterface 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 cassentPlaywright é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èsgoldens pris en PDF0, avec pdfjs-dist, avant la migration
tagged et outline expérimentaux dans Chromiume2e 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 pagepage propre à la case, avertissement ; jamais de coupe
Route d'impression des docs impossible sans patch du cœurspike PDF4.0 ; PDF4 optionnel ; repli sur scope: "page"
Fichiers générés en CI absents sur Vercel, boutons mortsbouton 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 ?