# Pdftoepub

> Conversion TRÈS PROPRE d'un PDF de livre vers un ePub refait à neuf (extraction de texte nettoyée + reconstruction de la structure), pas une conversion brute. Utiliser ce skill dès que l'utilisateur veut "convertir un PDF en epub", "un epub propre depuis un PDF", "refondre/refaire un livre PDF en epub", "extraire proprement le texte d'un PDF", ou fournit un ou plusieurs PDF de livre (essai, manuel, ouvrage académique, roman) à transformer en ePub lisible. Déclencher aussi pour : enlever les en-têtes courants et numéros de page, recoller les césures, reconstruire les paragraphes et citations, gérer les tableaux, les notes, le glossaire, la bibliographie, traiter un PDF scanné avec OCR, ou produire un ePub validé epubcheck. NE PAS utiliser pour une simple extraction de texte jetable (préférer markitdown/pdftotext) ni pour des PDF non-livres (formulaires, factures, tableurs). Le skill encode une méthode éprouvée + des scripts pour éviter de tâtonner : diagnostic auto, pipeline PyMuPDF configurable, assemblage pa

- Skill: `lehibou06/pdftoepub` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add lehibou06/pdftoepub`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lehibou06/pdftoepub/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: LeHibou06 (https://skillmd.com/u/lehibou06)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lehibou06/pdftoepub

---


# pdftoepub — PDF de livre → ePub propre

## Principe directeur

Une conversion "très propre" n'est PAS `pandoc livre.pdf -o livre.epub` ni `markitdown`.
Le PDF est une mise en page figée ; l'ePub est un flux refluable. Il faut **démonter**
la mise en page (en-têtes courants, numéros de page, colonnes, césures, indentations
qui codent les paragraphes) et **remonter** une structure logique (chapitres, titres,
paragraphes, citations, listes, tableaux). 

L'outil de base est **PyMuPDF (`fitz`)** : il donne le texte avec coordonnées (x0, y, taille
de police, gras) par ligne et par mot. Ces coordonnées sont indispensables — `pdftotext`
brut perd les sauts de paragraphe, `pdftotext -layout` garde l'indentation mais casse les
césures. Tout le reste découle de l'exploitation de la géométrie.

Sortie intermédiaire = **Markdown propre + images de tableaux**, puis **pandoc → ePub**,
puis **validation epubcheck**. Le Markdown reste éditable et vérifiable à chaque étape.

## Dépendances

- `python` ≥ 3.9 avec **PyMuPDF** (`import fitz`) et **Pillow** (planches-contact)
  — `pip install -r requirements.txt`
- `pandoc` — assemblage de l'ePub (Phase 6)
- `pdftotext` (poppler) — optionnel, pour le triage rapide uniquement
- `java` — pour epubcheck (`python scripts/install_epubcheck.py` une fois, Phase 6)
- `ocrmypdf` + **Tesseract** (`-l fra`/`eng`) — PDF scanné SANS couche texte (Phase 1bis)
- clé **Mistral** (`MISTRAL_API_KEY`) — **OCR-vision** (Phase 1ter, `mistral-ocr-latest`) :
  LA voie par défaut pour tout PDF (voir ci-dessous)
- clé **DeepSeek** (`DEEPSEEK_API_KEY`) — nettoyage OCR (Phase 5bis) et traduction EN→FR

Les clés sont résolues par `scripts/apikey.py` : variable d'environnement
`<PROVIDER>_API_KEY`, sinon `<PROVIDER>_API_KEY_FILE` (chemin d'un fichier), sinon
`%APPDATA%\pdftoepub\<provider>_api_key` (Windows) / `~/.config/pdftoepub/...` (Unix).
Aucune clé n'est écrite en dur ni committée.

Sur Windows, **toujours `PYTHONIOENCODING=utf-8`** devant les commandes python qui
impriment du texte de livre (sinon `UnicodeEncodeError` cp1252).

## Workflow en 6 phases

Travailler dans un dossier `_work/` à côté du/des PDF. Scripts dans `scripts/` :
`diagnose.py`, `extract.py` (corps + notes liées + tableaux), `config_template.py`,
`proofread.py` + `apply_corrections.py` (nettoyage OCR), `translate_clean.py`
(traduction EN→FR), et la CSS dans `assets/`.

### Phase 1 — Triage & diagnostic (NE PAS sauter : c'est ici qu'on perd du temps sinon)

Lancer le diagnostic automatique, qui consolide toute l'exploration manuelle :

```bash
PYTHONIOENCODING=utf-8 python <skill>/scripts/diagnose.py "Mon Livre.pdf"
```

Il imprime et écrit `_work/<nom>.diag.txt` :
- nombre de pages, **né-numérique vs scanné** (couverture image par page),
- **ratio de "vrais mots" par page** avec les pages aberrantes signalées
  (candidates pages-bruit OCR, MAIS aussi Références/Bibliographie qui sont légitimement
  basses — ne pas confondre, cf. Phase 3),
- le **sommaire** (texte de la zone CONTENTS) pour cartographier les chapitres,
- les **pages d'ouverture de chapitre** candidates (pages courtes / titres),
- les lignes **`Table n.n` / `TABLE n.n`** (emplacements de tableaux),
- un **squelette de config** pré-rempli à copier.

Lire ce rapport en entier avant de coder quoi que ce soit.

> **NORME (décide tout le reste). TOUT PDF passe par Mistral OCR (Phase 1ter).**
> C'est la voie par défaut — scanné, scanné-à-couche-OCR ET né-numérique. Mistral OCR
> LIT les images de page et rend un Markdown propre, refluant, structuré en un appel :
> c'est la manière SÛRE et reproductible d'obtenir un texte propre, sans tâtonner. Ne
> PAS commencer par `extract.py`/PyMuPDF « parce que le PDF est propre » — on perd des
> heures à rapiécer césures, en-têtes, paragraphes au regex (puits sans fond).
> Coût ~1 $/1000 pages : négligeable face au temps gagné.
>
> - **Voie unique par défaut → Phase 1ter** (`ocr_vision.py`), quel que soit le PDF.
>   Puis nettoyage léger et borné (front-matter, recollage des paragraphes coupés par
>   saut de page, niveaux de titre via le sommaire) → Phase 6.
> - **PyMuPDF (`extract.py`, Phases 2-5) = repli ponctuel**, seulement si Mistral
>   abîme un cas précis qu'on veut exact : tableaux denses, notes liées par police, ou
>   **récupération de structure** (ci-dessous). On extrait alors la couche native juste
>   pour ce besoin, on ne rebâtit pas tout le livre avec.
>
> **Piège connu — bandeaux de titres GRAPHIQUES (livres né-numériques très maquettés).**
> Mistral transcrit le CORPS mais saute souvent les titres de chapitre rendus en image
> stylisée (bandeaux décoratifs). Symptôme : beaucoup d'entrées du sommaire absentes du
> Markdown Mistral. Le texte n'est pas perdu (la prose y est), seuls les séparateurs de
> chapitre manquent. **Récupération** : la couche texte native (`get_text()`, à
> **dédoubler** ligne-à-ligne si drop-shadow) contient souvent ces titres → mapper
> chaque titre du SOMMAIRE à sa PAGE via la native, puis ré-OCRiser avec `--marker`
> (`<!--PAGE n-->`) et injecter `# Titre` à la bonne page (patron prêt à adapter :
> `scripts/example_recover_graphic_titles.py`). Couverture typique ~⅓ des titres si le
> reste est 100 % graphique — l'assumer et le signaler.
>
> **Exposants ordinaux FR** (`3$^{e}$`, `1$^{er}$`…) : Mistral les rend en LaTeX →
> `pandoc+smart` les afficherait en maths. `finalize.py` les convertit en exposants
> Unicode (ᵉ, ᵉʳ…) ; filet qui retire aussi `$$\frac{}{}$$` résiduels.

### Phase 1ter — Scan à couche OCR → voie OCR-VISION (Mistral OCR) — DÉFAUT des scans

Ne PAS lire la couche texte. Envoyer le PDF à `mistral-ocr-latest` qui lit les **images
de page** et rend un Markdown propre et structuré par page :

```bash
cd _work && PYTHONIOENCODING=utf-8 python <skill>/scripts/ocr_vision.py \
  "../Mon Livre.pdf" -o monlivre.md            # tout le livre, 1 appel ; +--images si figures
```

- Coût ~1 $/1000 pages ; limites ~1000 pages / 50 Mo (au-delà, `--batch` découpe).
- Le script retire les **folios nus**, marque les **lignes ALLCAPS courtes en `##`**
  (Mistral ne balise pas toujours les titres), et concatène. Résultat : corps **sans**
  glyphes cassés, **sans** en-têtes fuités, césures recollées, paragraphes refluants.
- **Ce qu'il reste à faire (léger, borné)** — PAS de whack-a-mole :
  1. **Recoller les paragraphes coupés par un saut de page** : le script joint les pages
     en `\n\n`, donc un paragraphe à cheval sur deux pages devient deux blocs. Règle sûre :
     fusionner si le bloc d'avant ne finit PAS par une ponctuation forte (`.!?:;»"…`) ET
     le bloc d'après commence par une **minuscule** (≠ titre, ≠ tiret de dialogue, ≠ `(N)`).
     Universel : tout livre multi-pages le subit (testé : L'Inde, jonctions 0 mot perdu).
  2. **Leveling des titres** : Mistral mêle `#`/`##` ; promouvoir les ouvertures de
     chapitre en `#` via le sommaire (Phase 2), démoter le faux-titre du front-matter
     (page de titre, « DU MÊME AUTEUR »). Fusionner un n° de chapitre + son titre, et
     fusionner un label seul + son titre adjacent (`# INTRODUCTION` + `# LA NAISSANCE…`
     → `# INTRODUCTION — LA NAISSANCE…`, sinon section vide dans l'ePub).
  3. **Filet anti-hallucination** : Mistral OCR est fidèle (transcription, pas
     génération) mais vérifier — `diff`/longueur vs la couche `get_text()` page à page ;
     une page qui diverge énormément (texte avalé/inventé) → la rejouer ou relire.
  4. **Figures** : si `--images` n'a pas isolé les diagrammes (un scan de prose renvoie
     la page entière), les recadrer du PDF comme en Phase 6 (`get_pixmap(clip=Rect)`).
  5. **Notes** : Mistral rend parfois les appels en `[^n]` directement (rien à faire) ;
     sinon il garde les **notes parenthésées `(N)`** (appel inline `mot (N)` + définition
     `(N) texte…` en bas de page). ATTENTION : la voie vision n'a **aucune info de police**
     → la « séparation par taille » de la Phase 5 est inapplicable ici. Convertir plutôt
     **par chapitre** (les `(N)` se renumérotent à chaque chapitre) : pour chaque section
     `#`, collecter les définitions `^\(N\)\s`, leur donner un id unique `[^cK-N]`, puis ne
     remplacer un appel `(N)` que si N est défini dans CE chapitre (gate anti-faux-positif
     années/énumérations ; exiger `(?<!\d)`). Orphelins (appel mangé par l'OCR) → réattacher
     l'appel en fin de paragraphe pour ne rien perdre. Testé sur L'Inde : **216/217 liées**.
- **Nettoyage borné automatique** : `scripts/finalize.py monlivre.clean.md` fait, en une
  passe et de façon idempotente : recollage des paragraphes coupés par saut de page,
  suppression des refs `![img]` orphelines / bandeaux `## XXXX` / folios nus / **titres
  vides** (sinon erreur epubcheck RSC-005 « anchors must contain text »), et conversion
  des **exposants LaTeX** en Unicode. Le lancer sur tout `.clean.md` avant pandoc.
- Puis directement **Phase 6** (métadonnées YAML, couverture, pandoc, epubcheck).
  Sauter les Phases 2-5/5bis spécifiques PyMuPDF (extraction, config, proofread DeepSeek
  — inutile, le texte est déjà propre).

### Phase 1bis — PDF scanné SANS couche texte → OCR (sinon sauter)

Si le diag dit « SCANNÉ » MAIS que `fitz` ne renvoie aucun texte
(`page.get_text()` vide partout : `python -c "import fitz;d=fitz.open('L.pdf');print([len(d[i].get_text()) for i in range(0,6)])"`),
il faut OCRiser AVANT tout. Une seule commande (tourne en arrière-plan ~5-10 min,
faire la cartographie pendant ce temps) :

```bash
cd _work && ocrmypdf -l fra --rotate-pages --deskew --force-ocr --output-type pdf \
  "../Mon Livre.pdf" "monlivre_ocr.pdf" > ocr.log 2>&1 &
```

- `--rotate-pages` corrige une orientation erronée (Tesseract OSD) ; `--deskew` redresse.
  Note : la **métadonnée de rotation** d'un PDF est souvent déjà correcte (les pages
  s'affichent droites via `fitz`/un vrai lecteur) — un rendu Calibre fautif ≠ PDF fautif :
  rendre 2-3 pages avec `get_pixmap()` pour trancher, ne pas re-tourner à l'aveugle.
- Ensuite **tout le pipeline pointe sur `monlivre_ocr.pdf`** (qui porte image + texte),
  y compris la couverture. Re-lancer `diagnose.py` dessus pour le squelette de config.
- **Réglages de config pour un scan FR** (sinon on perd des cycles) :
  `"strict_para": True`, `"indent_min": 6` (le défaut 12 est trop haut, cf. Phase 3),
  `"footnotes": True` si notes de bas de page (cf. arbre Phase 3), et offset
  imprimé→PDF souvent **+2** (le vérifier sur l'Index dont on connaît la page imprimée).

### Phase 2 — Cartographie de la structure

À partir du sommaire et des marqueurs, établir la **table de correspondance
page imprimée → page PDF** (l'offset est constant dans le corps ; le vérifier sur
Notes/Index dont on connaît la page imprimée). Lister, par section :
préfaces, chapitres (avec leur **page PDF d'ouverture**), Notes, Glossaire,
Références/Bibliographie, Index (à **omettre** : inutile en reflowable).

**Fidélité de la numérotation** : si le texte fait des renvois "chapter 5", vérifier que
ma numérotation colle (`grep -oiE "chapter [0-9]+"`), puis tester à quoi renvoie
"chapter 1" pour décider si une Introduction compte comme chapitre 1. Respecter la
numérotation d'origine, sinon les renvois internes deviennent faux.

### Phase 3 — Configuration

Copier `config_template.py` en `_work/config.py` et remplir, par livre, la liste
`sections` (titre + plages de pages PDF `start`/`end`, `end` exclusif = `start` de la
suivante) avec le bon **layout** par section (voir l'arbre ci-dessous), plus
`manual_bands` (tableaux récalcitrants) et `skip_pages` (pages-bruit scannées).

**Arbre de décision — layout par section :**
- Corps de chapitre → `"body"` (défaut) : renfoncement 1ʳᵉ ligne = nouveau paragraphe ;
  lignes consécutivement indentées = citation `>` ; reprise à la marge de base = fin de citation.
- **Notes** de fin (section séparée, numérotées) → `"notes"` (paragraphe, sans citations).
- **Bibliographie / Références** (hanging indent : 1ʳᵉ ligne à la marge, suite indentée) → `"hanging"`.
- **Glossaire** (mot-vedette en gras, pas d'indentation) → `"glossary"` (détecte les entrées au gras).

**Réglages livre (pas par section), à mettre dans le dict du livre :**
- **`"indent_min": 6`** pour tout scan/OCR : le défaut **12** est calé pour le né-numérique
  EN mais les scans FR ont un renfoncement ~+11pt → avec 12 **tous les paragraphes
  fusionnent** en pavés (le bug le plus coûteux à diagnostiquer tard ; cf. checklist).
- **`"footnotes": True`** quand le livre a des **notes de bas de page** : `extract.py`
  sépare le bloc de notes en bas de chaque page et le ré-émet en **notes liées pandoc
  `[^id]`** (appel = chiffre nu ; numérotation par partie OK). Sinon les notes se
  déversent dans le corps ET les citations EN MAJUSCULES (Patrologie « CLXXXII, 985 »)
  deviennent de **faux titres `##`**. Pandoc route chaque note vers le bon chapitre.

**Quand ajouter un `manual_bands`** (tableau non auto-détecté) :
- tableau à 2 colonnes sans gros écart horizontal intra-ligne (auto-détection le rate),
- tableau **paysage / pivoté 90°** ou **étalé sur 2 pages** (texte éclaté),
- donner `(y0, y1, "Légende"[, x0, x1])` en points PDF (lus dans le diag/inspection).

**Quand ajouter `skip_pages`** : pages entièrement bruit OCR (entre deux préfaces, avant
une page-titre de chapitre). Les repérer à l'œil dans le diag ; **ne jamais** les détecter
par un seuil de ratio global (casse Références/Bibliographie et tableaux légitimes).

### Phase 4 — Extraction

```bash
cd _work && PYTHONIOENCODING=utf-8 python <skill>/scripts/extract.py config.py
```

Produit `_work/<nom>.md` + `_work/img/<nom>_tblNN.png`. Le pipeline gère
automatiquement : suppression en-têtes/numéros, recollage des césures intelligent,
espaces perdus aux jonctions de spans, marqueurs de note en exposant, petites
capitales espacées ("T H E" → mots), paragraphes/citations, tableaux→images.

### Phase 5 — Vérification (boucle qualité, voir `references/checklist.md`)

Lancer les sondes (charabia résiduel, tokens collés = césures ratées, plan des titres,
ouvertures de chapitre complètes), lire 2-3 extraits de corps, et **inspecter
visuellement les images de tableaux** via une planche-contact PIL. Corriger la config
et relancer Phase 4 jusqu'à 0 anomalie. Détails et commandes : `references/checklist.md`.

**Vérifier TÔT la segmentation des paragraphes** (sinon on découvre trop tard un
`indent_min` mal réglé) — compter les blocs par section :
```bash
PYTHONIOENCODING=utf-8 python -c "import re;md=open('x.md',encoding='utf-8').read();[print(len([p for p in s.split(chr(10)+chr(10)) if p.strip() and not p.startswith('[^')]),'|',s.split(chr(10))[0][:45]) for s in re.split(r'^# ',md,flags=re.M)[1:]]"
```
Une section de plusieurs pages avec **1-2 blocs** = paragraphes fusionnés → baisser
`indent_min` (mesurer le renfoncement réel : x0 des ouvertures vs continuations).

### Phase 5bis — Nettoyage OCR (livres scannés uniquement)

Pour un scan, l'OCR laisse des coquilles (« diaiogue », « H discourt » pour « Il »,
« fbid » pour « Ibid », guillemets `>`…). Relecture conservatrice DeepSeek, EN UN SEUL
PASSAGE (limites V4 Pro vérifiées : contexte 128K, sortie 64K → gros chunks) :

```bash
cd _work && PYTHONIOENCODING=utf-8 python <skill>/scripts/proofread.py x.md x.corr.json \
  --hint "de spiritualité, Auteur (année)"            # ~5 appels pour 300K caractères
# applique le sous-ensemble SÛR (frontières de mot, stoplist, garde anti-masse) + .bak
PYTHONIOENCODING=utf-8 python <skill>/scripts/apply_corrections.py x.md x.corr.json
```

- `proofread.py` renvoie une liste `{wrong,right}` (PAS de réécriture) ; `apply_corrections.py`
  n'applique QUE le sûr : `wrong` long (≥6) / avec garbage / multi-mots, borné aux
  **frontières de mot** (« import »→« importe » ne casse pas « important »), rejette les
  petits mots valides et les substitutions frappant >3 endroits (genre/nombre contextuels).
- **NE PAS** rétrécir les chunks (l'ancien 14000 car/`max_tokens` 8000 **tronquait** le
  JSON → 2e passe inutile). Si un chunk sort `finish=length`, le script le signale.
- Re-vérifier ensuite quelques coquilles connues à 0 et que le corps n'est pas corrompu
  (`important` intact, etc.). Les `multi>3` ignorées : appliquer à la main si vraiment sûres.

### Phase 6 — Assemblage ePub + validation

Métadonnées YAML par livre (`meta_<nom>.yaml` : title, author, language, publisher, date,
rights), couverture rendue depuis la page 1 du PDF, CSS depuis `assets/epub.css`.

```bash
# couverture
PYTHONIOENCODING=utf-8 python -c "import fitz; fitz.open('Livre.pdf')[0].get_pixmap(matrix=fitz.Matrix(2,2)).save('_work/img/cover.png')"

# assemblage (lancer depuis _work pour que img/... résolve)
cd _work && pandoc meta_x.yaml x.md --from=markdown+smart --toc --toc-depth=2 \
  --split-level=1 --css=epub.css --epub-cover-image=img/cover.png \
  --metadata lang=fr -o "../Titre Propre - Auteur.epub"
```

**Validation obligatoire** (epubcheck = la référence pour "propre") :
```bash
# une seule fois : télécharge epubcheck depuis github.com/w3c/epubcheck/releases
python <skill>/scripts/install_epubcheck.py        # -> <skill>/epubcheck-<ver>/epubcheck.jar
java -jar "<skill>/epubcheck-5.1.0/epubcheck.jar" "Titre Propre - Auteur.epub"
```
Viser **0 fatal / 0 erreur / 0 avertissement**.

## Partis pris à signaler à l'utilisateur

- **Index** omis (renvois par n° de page inutiles en reflowable).
- **Notes de bas de page** : avec `"footnotes": True`, ré-émises en **notes liées
  cliquables** pandoc (`[^id]`), routées au bon chapitre, renumérotées par pandoc.
  Quelques appels mangés par l'OCR (~15 %) sont rattachés en fin de ligne de leur page
  (note présente, placement parfois approximatif) — le signaler. Sans l'option, notes
  groupées sans numéro (ancien comportement).
- **Tableaux** rendus en **images** (fidélité exacte, zéro erreur de transcription) plutôt
  que retranscrits en Markdown. Inévitable pour les tableaux multidimensionnels.

## Pièges techniques (lecture rapide ; détails dans `references/gotchas.md`)

- `pdftotext` brut **fusionne les paragraphes** ; `-layout` ambigu sur les césures →
  toujours PyMuPDF avec coordonnées.
- **Pas de ligne vide entre paragraphes** dans la plupart des PDF → détecter via
  l'indentation x0 (marge de base vs renfoncement), pas via les blancs.
- **Renfoncement 1ʳᵉ ligne ET citation en bloc** sont au même x0 → distinguer par la suite :
  un paragraphe redescend à la marge, une citation reste indentée. Le texte après une
  citation reprend **sans renfoncement**.
- **Césures** : recoller `mot- suite` ; garder le trait d'union seulement si le radical est
  un mot entier (préfixe/suffixe) ET la suite n'est pas un simple suffixe flexionnel
  ("real-ized"→"realized", mais "structural-hierarchical" gardé).
- **Pages d'ouverture de chapitre** : titre parfois en **image** (OCR illisible) → injecter
  le titre depuis le sommaire ; sauter les lignes courtes jusqu'à la 1ʳᵉ ligne longue
  (NE PAS filtrer par taille de police, sinon on perd la lettrine de la 1ʳᵉ phrase).
- **Tableaux** linéarisés en charabia → images ; distinguer la vraie légende "TABLE n.n"
  d'un renvoi du corps "Table 1.1 is a map…" via la majuscule après le numéro.
- **Détection page-bruit** : le ratio de mots **varie par livre** et la Bibliographie est
  légitimement basse → liste `skip_pages` explicite, jamais de seuil global.

