I built an EPUB-to-Markdown converter expecting a clean pipeline: unzip, parse, walk the files, emit Markdown. The early version walked the manifest in alphabetical order. Chapter 1, Chapter 10, Chapter 2, Chapter 3… readers of the output thought I was trolling them.
EPUB is a ZIP of XHTML files plus a control file called content.opf. The manifest lists items by ID. The spine lists those IDs in reading order. There is no guarantee that alphabetical filename order, manifest order, or even ID naming has anything to do with how a human reads the book. I've seen ch1.xhtml, chapter_01.xhtml, part1.xhtml, intro.xhtml — all shuffled.
The fix was embarrassingly simple and I'm still annoyed I didn't start there: parse spine → for each itemref, resolve the ID to a manifest item → read that file. If a spine entry points to a non-XHTML resource, skip it gracefully. That's it. The "table of contents" you feel when reading a book is a second file (toc.ncx or the EPUB3 nav document) and it can disagree with the spine. I stopped trying to be clever and just follow the spine.
Second lesson: linear text extraction destroys books that aren't linear. Footnotes, sidebars, and pull-quotes are separate XHTML files referenced inline. My first output dropped them silently. Now I inline footnote bodies at the point of reference, marked with a > [^n]: prefix, so the Markdown stays self-contained for an LLM context window. Sidebars become blockquotes. It's not perfect, but it's honest.
Third lesson: EPUB2 and EPUB3 use different navigation files. Supporting both meant two parsers and a shim. The shim is worth it — half the "broken" reports I got were EPUB2 books with a .ncx and no nav document.
The result feeds clean chapter-structured Markdown into RAG pipelines: # Chapter 3: The Thing That Happened as the heading, body text below, footnotes inlined. Agents citing "chapter 3" actually mean chapter 3.
If you're building anything that touches EPUB, internal package documents, or really any "ebook format," the lesson generalizes: the file listing is not the reading order, and the TOC is not the structure. Find the manifest of intent and follow it.
I packaged the whole thing as a single-call API that takes a URL or base64 bytes and returns Markdown with chapter structure preserved: https://x402.freeq.one/tools/epub_to_markdown.html
What's the weirdest "the obvious thing was hidden in a control file" bug you've hit in a format you thought you understood?
Top comments (0)