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.txtpandoc— assemblage de l'ePub (Phase 6)pdftotext(poppler) — optionnel, pour le triage rapide uniquementjava— pour epubcheck (python scripts/install_epubcheck.pyune 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 :
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+smartles afficherait en maths.finalize.pyles 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 :
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à,
--batchdé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 :
- 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). - 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). - Filet anti-hallucination : Mistral OCR est fidèle (transcription, pas
génération) mais vérifier —
diff/longueur vs la coucheget_text()page à page ; une page qui diverge énormément (texte avalé/inventé) → la rejouer ou relire. - Figures : si
--imagesn'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)). - Notes : Mistral rend parfois les appels en
[^n]directement (rien à faire) ; sinon il garde les notes parenthésées(N)(appel inlinemot (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.
- Recoller les paragraphes coupés par un saut de page : le script joint les pages
en
- Nettoyage borné automatique :
scripts/finalize.py monlivre.clean.mdfait, 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.mdavant 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) :
cd _work && ocrmypdf -l fra --rotate-pages --deskew --force-ocr --output-type pdf \
"../Mon Livre.pdf" "monlivre_ocr.pdf" > ocr.log 2>&1 &
--rotate-pagescorrige une orientation erronée (Tesseract OSD) ;--deskewredresse. Note : la métadonnée de rotation d'un PDF est souvent déjà correcte (les pages s'affichent droites viafitz/un vrai lecteur) — un rendu Calibre fautif ≠ PDF fautif : rendre 2-3 pages avecget_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-lancerdiagnose.pydessus 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": Truesi 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": 6pour 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": Truequand le livre a des notes de bas de page :extract.pysé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
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 :
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) :
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.pyrenvoie une liste{wrong,right}(PAS de réécriture) ;apply_corrections.pyn'applique QUE le sûr :wronglong (≥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_tokens8000 tronquait le JSON → 2e passe inutile). Si un chunk sortfinish=length, le script le signale. - Re-vérifier ensuite quelques coquilles connues à 0 et que le corps n'est pas corrompu
(
importantintact, etc.). Lesmulti>3ignoré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.
# 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") :
# 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)
pdftotextbrut fusionne les paragraphes ;-layoutambigu 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_pagesexplicite, jamais de seuil global.