I wanted a PDF reflowed into a proper EPUB — the kind of book file you can actually read on an e-reader without pinch-zooming a fixed page. Most converters either butcher the layout or just don't handle scanned pages. So I built a small Python CLI, pdf2epub, using PyMuPDF for extraction, Tesseract for OCR fallback, and ebooklib to assemble the EPUB.
python3 -m venv venv
./venv/bin/pip install pymupdf ebooklib pytesseract Pillow
OCR needs the actual tesseract binary on PATH too:
brew install tesseract
PDFs often carry their own outline/bookmarks (what Acrobat shows as the sidebar TOC). PyMuPDF exposes this as doc.get_toc(). If it's there, that's the most reliable source of chapter boundaries — better than guessing from font sizes.
def toc_chapter_breaks(doc):
toc = doc.get_toc(simple=True)
if not toc:
return None
top_level = min(entry[0] for entry in toc)
breaks = []
for level, title, page in toc:
if level != top_level:
continue
page_idx = max(0, min(page - 1, doc.page_count - 1))
if breaks and breaks[-1][0] == page_idx:
continue
breaks.append((page_idx, title.strip() or f"Chapter {len(breaks) + 1}"))
if breaks and breaks[0][0] != 0:
breaks.insert(0, (0, "Introduction"))
return breaks or None
If there's no outline, fall back to spotting headings by font size — a short line that's noticeably bigger than the surrounding body text is probably a chapter title:
def heading_chapter_breaks(doc, body_size):
threshold = body_size * HEADING_SIZE_RATIO # 1.3x body size
breaks = []
for page_idx, page in enumerate(doc):
for block in page.get_text("dict")["blocks"]:
if block["type"] != 0 or not block["lines"]:
continue
first_line = block["lines"][0]
text = "".join(s["text"] for s in first_line["spans"]).strip()
if not text or len(text) > HEADING_MAX_LEN:
continue
max_size = max(s["size"] for s in first_line["spans"])
if max_size >= threshold:
breaks.append((page_idx, text))
return breaks if len(breaks) >= 2 else None
And if neither works, the whole document just becomes one chapter. Three-tier fallback, always produces something readable.
page.get_text("dict") gives back blocks with bounding boxes — both text and embedded images. Sort by vertical position and you get roughly the right reading order:
blocks = page.get_text("dict")["blocks"]
blocks.sort(key=lambda b: (round(b["bbox"][1]), b["bbox"][0]))
Text blocks become <p> or <h2> (same oversized-short-line heuristic as chapter detection, just per-page). Image blocks get pulled out, re-encoded as PNG, and dropped inline as <img> tags — so a diagram stays right next to the paragraph that references it instead of getting stripped out.
Some PDFs are just photos of pages with no real text layer — page.get_text("text") returns next to nothing. Detect that and render+OCR instead:
OCR_MIN_CHARS = 20
def ocr_page(page, lang):
pix = page.get_pixmap(dpi=300)
img = Image.open(io.BytesIO(pix.tobytes("png")))
return pytesseract.image_to_string(img, lang=lang)
This check runs per page, not per document — handy for PDFs that mix real text pages with the occasional scanned insert (a signed form in the middle of an otherwise digital book, say).
ebooklib handles the container format — manifest, spine, nav, NCX. Each chapter is just an EpubHtml item:
chap = epub.EpubHtml(
title=chap_title,
file_name=f"chap_{idx + 1:03d}.xhtml",
lang=args.lang,
content=html,
)
book.add_item(chap)
epub_chapters.append(chap)
...
book.toc = epub_chapters
book.spine = ["nav"] + epub_chapters
epub.write_epub(str(output_path), book)
Metadata (title/author) comes from the PDF itself if present, with CLI flags to override.
Point it at a directory instead of a file and it'll glob every *.pdf inside and convert the lot, writing failures to stderr without aborting the whole run:
./venv/bin/python3 pdf2epub.py /path/to/pdf_folder --outdir out/
One bad/corrupt PDF in a batch of fifty shouldn't kill the other forty-nine.