Build a PDF to EPUB Converter in Python

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.

What it does

  • Pulls chapters straight from the PDF's own table of contents/outline
  • Falls back to a font-size heuristic (big short lines = headings) if there's no outline
  • Extracts text and images in reading order, reflowed into normal paragraphs
  • Auto-detects scanned pages (little/no text layer) and OCRs them with Tesseract
  • Batch mode — point it at a folder, get a folder of EPUBs back

Setup

python3 -m venv venv
./venv/bin/pip install pymupdf ebooklib pytesseract Pillow

OCR needs the actual tesseract binary on PATH too:

brew install tesseract

Chapter detection

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.

Extracting content in reading order

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.

OCR fallback for scanned pages

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).

Assembling the EPUB

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.

Batch mode

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.