[{"data":1,"prerenderedAt":610},["ShallowReactive",2],{"home":3,"content-query-2HAwpxGx4N":355},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"draft":6,"tags":11,"body":18,"_type":349,"_id":350,"_source":351,"_file":352,"_stem":353,"_extension":354},"\u002Fposts\u002Fbuild-a-pdf-to-epub-converter-in-python","posts",false,"","Build a PDF to EPUB Converter in Python","A CLI tool that converts PDFs into reflowable EPUBs, with chapter detection, inline images, and an OCR fallback for scanned pages.","2026-07-28T08:32:52+05:30",[12,13,14,15,16,17],"Python","PDF","EPUB","CLI","OCR","PyMuPDF",{"type":19,"children":20,"toc":339},"root",[21,46,53,83,89,101,114,123,129,142,153,158,167,180,186,197,206,235,241,254,263,268,274,292,301,306,312,325,334],{"type":22,"tag":23,"props":24,"children":25},"element","p",{},[26,29,36,38,44],{"type":27,"value":28},"text","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, ",{"type":22,"tag":30,"props":31,"children":33},"code",{"className":32},[],[34],{"type":27,"value":35},"pdf2epub",{"type":27,"value":37},", using PyMuPDF for extraction, Tesseract for OCR fallback, and ",{"type":22,"tag":30,"props":39,"children":41},{"className":40},[],[42],{"type":27,"value":43},"ebooklib",{"type":27,"value":45}," to assemble the EPUB.",{"type":22,"tag":47,"props":48,"children":50},"h2",{"id":49},"what-it-does",[51],{"type":27,"value":52},"What it does",{"type":22,"tag":54,"props":55,"children":56},"ul",{},[57,63,68,73,78],{"type":22,"tag":58,"props":59,"children":60},"li",{},[61],{"type":27,"value":62},"Pulls chapters straight from the PDF's own table of contents\u002Foutline",{"type":22,"tag":58,"props":64,"children":65},{},[66],{"type":27,"value":67},"Falls back to a font-size heuristic (big short lines = headings) if there's no outline",{"type":22,"tag":58,"props":69,"children":70},{},[71],{"type":27,"value":72},"Extracts text and images in reading order, reflowed into normal paragraphs",{"type":22,"tag":58,"props":74,"children":75},{},[76],{"type":27,"value":77},"Auto-detects scanned pages (little\u002Fno text layer) and OCRs them with Tesseract",{"type":22,"tag":58,"props":79,"children":80},{},[81],{"type":27,"value":82},"Batch mode — point it at a folder, get a folder of EPUBs back",{"type":22,"tag":47,"props":84,"children":86},{"id":85},"setup",[87],{"type":27,"value":88},"Setup",{"type":22,"tag":90,"props":91,"children":96},"pre",{"className":92,"code":94,"language":95,"meta":7},[93],"language-shell","python3 -m venv venv\n.\u002Fvenv\u002Fbin\u002Fpip install pymupdf ebooklib pytesseract Pillow\n","shell",[97],{"type":22,"tag":30,"props":98,"children":99},{"__ignoreMap":7},[100],{"type":27,"value":94},{"type":22,"tag":23,"props":102,"children":103},{},[104,106,112],{"type":27,"value":105},"OCR needs the actual ",{"type":22,"tag":30,"props":107,"children":109},{"className":108},[],[110],{"type":27,"value":111},"tesseract",{"type":27,"value":113}," binary on PATH too:",{"type":22,"tag":90,"props":115,"children":118},{"className":116,"code":117,"language":95,"meta":7},[93],"brew install tesseract\n",[119],{"type":22,"tag":30,"props":120,"children":121},{"__ignoreMap":7},[122],{"type":27,"value":117},{"type":22,"tag":47,"props":124,"children":126},{"id":125},"chapter-detection",[127],{"type":27,"value":128},"Chapter detection",{"type":22,"tag":23,"props":130,"children":131},{},[132,134,140],{"type":27,"value":133},"PDFs often carry their own outline\u002Fbookmarks (what Acrobat shows as the sidebar TOC). PyMuPDF exposes this as ",{"type":22,"tag":30,"props":135,"children":137},{"className":136},[],[138],{"type":27,"value":139},"doc.get_toc()",{"type":27,"value":141},". If it's there, that's the most reliable source of chapter boundaries — better than guessing from font sizes.",{"type":22,"tag":90,"props":143,"children":148},{"className":144,"code":146,"language":147,"meta":7},[145],"language-python","def toc_chapter_breaks(doc):\n    toc = doc.get_toc(simple=True)\n    if not toc:\n        return None\n    top_level = min(entry[0] for entry in toc)\n    breaks = []\n    for level, title, page in toc:\n        if level != top_level:\n            continue\n        page_idx = max(0, min(page - 1, doc.page_count - 1))\n        if breaks and breaks[-1][0] == page_idx:\n            continue\n        breaks.append((page_idx, title.strip() or f\"Chapter {len(breaks) + 1}\"))\n    if breaks and breaks[0][0] != 0:\n        breaks.insert(0, (0, \"Introduction\"))\n    return breaks or None\n","python",[149],{"type":22,"tag":30,"props":150,"children":151},{"__ignoreMap":7},[152],{"type":27,"value":146},{"type":22,"tag":23,"props":154,"children":155},{},[156],{"type":27,"value":157},"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:",{"type":22,"tag":90,"props":159,"children":162},{"className":160,"code":161,"language":147,"meta":7},[145],"def heading_chapter_breaks(doc, body_size):\n    threshold = body_size * HEADING_SIZE_RATIO  # 1.3x body size\n    breaks = []\n    for page_idx, page in enumerate(doc):\n        for block in page.get_text(\"dict\")[\"blocks\"]:\n            if block[\"type\"] != 0 or not block[\"lines\"]:\n                continue\n            first_line = block[\"lines\"][0]\n            text = \"\".join(s[\"text\"] for s in first_line[\"spans\"]).strip()\n            if not text or len(text) > HEADING_MAX_LEN:\n                continue\n            max_size = max(s[\"size\"] for s in first_line[\"spans\"])\n            if max_size >= threshold:\n                breaks.append((page_idx, text))\n    return breaks if len(breaks) >= 2 else None\n",[163],{"type":22,"tag":30,"props":164,"children":165},{"__ignoreMap":7},[166],{"type":27,"value":161},{"type":22,"tag":23,"props":168,"children":169},{},[170,172,178],{"type":27,"value":171},"And if neither works, the whole document just becomes one chapter. Three-tier fallback, always produces ",{"type":22,"tag":173,"props":174,"children":175},"em",{},[176],{"type":27,"value":177},"something",{"type":27,"value":179}," readable.",{"type":22,"tag":47,"props":181,"children":183},{"id":182},"extracting-content-in-reading-order",[184],{"type":27,"value":185},"Extracting content in reading order",{"type":22,"tag":23,"props":187,"children":188},{},[189,195],{"type":22,"tag":30,"props":190,"children":192},{"className":191},[],[193],{"type":27,"value":194},"page.get_text(\"dict\")",{"type":27,"value":196}," gives back blocks with bounding boxes — both text and embedded images. Sort by vertical position and you get roughly the right reading order:",{"type":22,"tag":90,"props":198,"children":201},{"className":199,"code":200,"language":147,"meta":7},[145],"blocks = page.get_text(\"dict\")[\"blocks\"]\nblocks.sort(key=lambda b: (round(b[\"bbox\"][1]), b[\"bbox\"][0]))\n",[202],{"type":22,"tag":30,"props":203,"children":204},{"__ignoreMap":7},[205],{"type":27,"value":200},{"type":22,"tag":23,"props":207,"children":208},{},[209,211,217,219,225,227,233],{"type":27,"value":210},"Text blocks become ",{"type":22,"tag":30,"props":212,"children":214},{"className":213},[],[215],{"type":27,"value":216},"\u003Cp>",{"type":27,"value":218}," or ",{"type":22,"tag":30,"props":220,"children":222},{"className":221},[],[223],{"type":27,"value":224},"\u003Ch2>",{"type":27,"value":226}," (same oversized-short-line heuristic as chapter detection, just per-page). Image blocks get pulled out, re-encoded as PNG, and dropped inline as ",{"type":22,"tag":30,"props":228,"children":230},{"className":229},[],[231],{"type":27,"value":232},"\u003Cimg>",{"type":27,"value":234}," tags — so a diagram stays right next to the paragraph that references it instead of getting stripped out.",{"type":22,"tag":47,"props":236,"children":238},{"id":237},"ocr-fallback-for-scanned-pages",[239],{"type":27,"value":240},"OCR fallback for scanned pages",{"type":22,"tag":23,"props":242,"children":243},{},[244,246,252],{"type":27,"value":245},"Some PDFs are just photos of pages with no real text layer — ",{"type":22,"tag":30,"props":247,"children":249},{"className":248},[],[250],{"type":27,"value":251},"page.get_text(\"text\")",{"type":27,"value":253}," returns next to nothing. Detect that and render+OCR instead:",{"type":22,"tag":90,"props":255,"children":258},{"className":256,"code":257,"language":147,"meta":7},[145],"OCR_MIN_CHARS = 20\n\ndef ocr_page(page, lang):\n    pix = page.get_pixmap(dpi=300)\n    img = Image.open(io.BytesIO(pix.tobytes(\"png\")))\n    return pytesseract.image_to_string(img, lang=lang)\n",[259],{"type":22,"tag":30,"props":260,"children":261},{"__ignoreMap":7},[262],{"type":27,"value":257},{"type":22,"tag":23,"props":264,"children":265},{},[266],{"type":27,"value":267},"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).",{"type":22,"tag":47,"props":269,"children":271},{"id":270},"assembling-the-epub",[272],{"type":27,"value":273},"Assembling the EPUB",{"type":22,"tag":23,"props":275,"children":276},{},[277,282,284,290],{"type":22,"tag":30,"props":278,"children":280},{"className":279},[],[281],{"type":27,"value":43},{"type":27,"value":283}," handles the container format — manifest, spine, nav, NCX. Each chapter is just an ",{"type":22,"tag":30,"props":285,"children":287},{"className":286},[],[288],{"type":27,"value":289},"EpubHtml",{"type":27,"value":291}," item:",{"type":22,"tag":90,"props":293,"children":296},{"className":294,"code":295,"language":147,"meta":7},[145],"chap = epub.EpubHtml(\n    title=chap_title,\n    file_name=f\"chap_{idx + 1:03d}.xhtml\",\n    lang=args.lang,\n    content=html,\n)\nbook.add_item(chap)\nepub_chapters.append(chap)\n...\nbook.toc = epub_chapters\nbook.spine = [\"nav\"] + epub_chapters\nepub.write_epub(str(output_path), book)\n",[297],{"type":22,"tag":30,"props":298,"children":299},{"__ignoreMap":7},[300],{"type":27,"value":295},{"type":22,"tag":23,"props":302,"children":303},{},[304],{"type":27,"value":305},"Metadata (title\u002Fauthor) comes from the PDF itself if present, with CLI flags to override.",{"type":22,"tag":47,"props":307,"children":309},{"id":308},"batch-mode",[310],{"type":27,"value":311},"Batch mode",{"type":22,"tag":23,"props":313,"children":314},{},[315,317,323],{"type":27,"value":316},"Point it at a directory instead of a file and it'll glob every ",{"type":22,"tag":30,"props":318,"children":320},{"className":319},[],[321],{"type":27,"value":322},"*.pdf",{"type":27,"value":324}," inside and convert the lot, writing failures to stderr without aborting the whole run:",{"type":22,"tag":90,"props":326,"children":329},{"className":327,"code":328,"language":95,"meta":7},[93],".\u002Fvenv\u002Fbin\u002Fpython3 pdf2epub.py \u002Fpath\u002Fto\u002Fpdf_folder --outdir out\u002F\n",[330],{"type":22,"tag":30,"props":331,"children":332},{"__ignoreMap":7},[333],{"type":27,"value":328},{"type":22,"tag":23,"props":335,"children":336},{},[337],{"type":27,"value":338},"One bad\u002Fcorrupt PDF in a batch of fifty shouldn't kill the other forty-nine.",{"title":7,"searchDepth":340,"depth":340,"links":341},2,[342,343,344,345,346,347,348],{"id":49,"depth":340,"text":52},{"id":85,"depth":340,"text":88},{"id":125,"depth":340,"text":128},{"id":182,"depth":340,"text":185},{"id":237,"depth":340,"text":240},{"id":270,"depth":340,"text":273},{"id":308,"depth":340,"text":311},"markdown","content:posts:build-a-pdf-to-epub-converter-in-python.md","content","posts\u002Fbuild-a-pdf-to-epub-converter-in-python.md","posts\u002Fbuild-a-pdf-to-epub-converter-in-python","md",{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"draft":6,"tags":356,"body":357,"_type":349,"_id":350,"_source":351,"_file":352,"_stem":353,"_extension":354},[12,13,14,15,16,17],{"type":19,"children":358,"toc":601},[359,375,379,402,406,414,424,432,436,446,454,458,466,475,479,488,496,518,522,532,540,544,548,563,571,575,579,589,597],{"type":22,"tag":23,"props":360,"children":361},{},[362,363,368,369,374],{"type":27,"value":28},{"type":22,"tag":30,"props":364,"children":366},{"className":365},[],[367],{"type":27,"value":35},{"type":27,"value":37},{"type":22,"tag":30,"props":370,"children":372},{"className":371},[],[373],{"type":27,"value":43},{"type":27,"value":45},{"type":22,"tag":47,"props":376,"children":377},{"id":49},[378],{"type":27,"value":52},{"type":22,"tag":54,"props":380,"children":381},{},[382,386,390,394,398],{"type":22,"tag":58,"props":383,"children":384},{},[385],{"type":27,"value":62},{"type":22,"tag":58,"props":387,"children":388},{},[389],{"type":27,"value":67},{"type":22,"tag":58,"props":391,"children":392},{},[393],{"type":27,"value":72},{"type":22,"tag":58,"props":395,"children":396},{},[397],{"type":27,"value":77},{"type":22,"tag":58,"props":399,"children":400},{},[401],{"type":27,"value":82},{"type":22,"tag":47,"props":403,"children":404},{"id":85},[405],{"type":27,"value":88},{"type":22,"tag":90,"props":407,"children":409},{"className":408,"code":94,"language":95,"meta":7},[93],[410],{"type":22,"tag":30,"props":411,"children":412},{"__ignoreMap":7},[413],{"type":27,"value":94},{"type":22,"tag":23,"props":415,"children":416},{},[417,418,423],{"type":27,"value":105},{"type":22,"tag":30,"props":419,"children":421},{"className":420},[],[422],{"type":27,"value":111},{"type":27,"value":113},{"type":22,"tag":90,"props":425,"children":427},{"className":426,"code":117,"language":95,"meta":7},[93],[428],{"type":22,"tag":30,"props":429,"children":430},{"__ignoreMap":7},[431],{"type":27,"value":117},{"type":22,"tag":47,"props":433,"children":434},{"id":125},[435],{"type":27,"value":128},{"type":22,"tag":23,"props":437,"children":438},{},[439,440,445],{"type":27,"value":133},{"type":22,"tag":30,"props":441,"children":443},{"className":442},[],[444],{"type":27,"value":139},{"type":27,"value":141},{"type":22,"tag":90,"props":447,"children":449},{"className":448,"code":146,"language":147,"meta":7},[145],[450],{"type":22,"tag":30,"props":451,"children":452},{"__ignoreMap":7},[453],{"type":27,"value":146},{"type":22,"tag":23,"props":455,"children":456},{},[457],{"type":27,"value":157},{"type":22,"tag":90,"props":459,"children":461},{"className":460,"code":161,"language":147,"meta":7},[145],[462],{"type":22,"tag":30,"props":463,"children":464},{"__ignoreMap":7},[465],{"type":27,"value":161},{"type":22,"tag":23,"props":467,"children":468},{},[469,470,474],{"type":27,"value":171},{"type":22,"tag":173,"props":471,"children":472},{},[473],{"type":27,"value":177},{"type":27,"value":179},{"type":22,"tag":47,"props":476,"children":477},{"id":182},[478],{"type":27,"value":185},{"type":22,"tag":23,"props":480,"children":481},{},[482,487],{"type":22,"tag":30,"props":483,"children":485},{"className":484},[],[486],{"type":27,"value":194},{"type":27,"value":196},{"type":22,"tag":90,"props":489,"children":491},{"className":490,"code":200,"language":147,"meta":7},[145],[492],{"type":22,"tag":30,"props":493,"children":494},{"__ignoreMap":7},[495],{"type":27,"value":200},{"type":22,"tag":23,"props":497,"children":498},{},[499,500,505,506,511,512,517],{"type":27,"value":210},{"type":22,"tag":30,"props":501,"children":503},{"className":502},[],[504],{"type":27,"value":216},{"type":27,"value":218},{"type":22,"tag":30,"props":507,"children":509},{"className":508},[],[510],{"type":27,"value":224},{"type":27,"value":226},{"type":22,"tag":30,"props":513,"children":515},{"className":514},[],[516],{"type":27,"value":232},{"type":27,"value":234},{"type":22,"tag":47,"props":519,"children":520},{"id":237},[521],{"type":27,"value":240},{"type":22,"tag":23,"props":523,"children":524},{},[525,526,531],{"type":27,"value":245},{"type":22,"tag":30,"props":527,"children":529},{"className":528},[],[530],{"type":27,"value":251},{"type":27,"value":253},{"type":22,"tag":90,"props":533,"children":535},{"className":534,"code":257,"language":147,"meta":7},[145],[536],{"type":22,"tag":30,"props":537,"children":538},{"__ignoreMap":7},[539],{"type":27,"value":257},{"type":22,"tag":23,"props":541,"children":542},{},[543],{"type":27,"value":267},{"type":22,"tag":47,"props":545,"children":546},{"id":270},[547],{"type":27,"value":273},{"type":22,"tag":23,"props":549,"children":550},{},[551,556,557,562],{"type":22,"tag":30,"props":552,"children":554},{"className":553},[],[555],{"type":27,"value":43},{"type":27,"value":283},{"type":22,"tag":30,"props":558,"children":560},{"className":559},[],[561],{"type":27,"value":289},{"type":27,"value":291},{"type":22,"tag":90,"props":564,"children":566},{"className":565,"code":295,"language":147,"meta":7},[145],[567],{"type":22,"tag":30,"props":568,"children":569},{"__ignoreMap":7},[570],{"type":27,"value":295},{"type":22,"tag":23,"props":572,"children":573},{},[574],{"type":27,"value":305},{"type":22,"tag":47,"props":576,"children":577},{"id":308},[578],{"type":27,"value":311},{"type":22,"tag":23,"props":580,"children":581},{},[582,583,588],{"type":27,"value":316},{"type":22,"tag":30,"props":584,"children":586},{"className":585},[],[587],{"type":27,"value":322},{"type":27,"value":324},{"type":22,"tag":90,"props":590,"children":592},{"className":591,"code":328,"language":95,"meta":7},[93],[593],{"type":22,"tag":30,"props":594,"children":595},{"__ignoreMap":7},[596],{"type":27,"value":328},{"type":22,"tag":23,"props":598,"children":599},{},[600],{"type":27,"value":338},{"title":7,"searchDepth":340,"depth":340,"links":602},[603,604,605,606,607,608,609],{"id":49,"depth":340,"text":52},{"id":85,"depth":340,"text":88},{"id":125,"depth":340,"text":128},{"id":182,"depth":340,"text":185},{"id":237,"depth":340,"text":240},{"id":270,"depth":340,"text":273},{"id":308,"depth":340,"text":311},1785212124723]