An EPUB is a zip of web pages
Rename the file to .zip and it opens: a manifest, a spine, and one XHTML file per chapter. The manifest lists every resource, the spine lists the reading order. That is the whole format — no binary blob, no layout coordinates, nothing like a PDF.
book.epub
├── mimetype # application/epub+zip, stored uncompressed
├── META-INF/container.xml # points at the OPF: "the package file is here"
└── OEBPS/
├── content.opf # metadata + manifest + spine (the reading order)
├── nav.xhtml # EPUB 3 table of contents
├── toc.ncx # EPUB 2 table of contents
├── ch01.xhtml … ch61.xhtml # the chapters, as XHTML
└── images/ # illustrations, often most of the file size
So the conversion is a re-serialisation, not an extraction: the converter walks the spine, turns each XHTML file into Markdown, and prepends the Dublin Core metadata as a block of bold lines. Compared with PDF, where heading levels have to be inferred from font size, this is close to lossless — for the text.
unzip -l book.epub shows what you are dealing with before you convert, and unzip -p book.epub META-INF/container.xml tells you where the OPF lives. If the listing shows META-INF/encryption.xml, read the DRM section below.What LlmFiles returns
Measured output for a two-chapter test book (2.9 KB, headings down to level 3, a list, a table with a header, a footnote, a code block and one image) — 1,437 characters, 64 lines. The metadata block comes first, then the chapters in spine order:
**Title:** Deep Work Notes
**Authors:** Book Author
**Language:** en
**Publisher:** Sample Press
**Date:** 2026-01-15
**Description:** A test book built to measure conversion.
**Identifier:** urn:uuid:acc1e2cc-18f4-40d8-8245-1e724705fdab
# Deep Work
## Introduction
Cal Newport argues that **focus** is a skill, not a talent, and that *shallow work*
fills the day by default. A [cited source](https://example.org/source) is linked.
### Rule one: measure
* Count the hours of deep work per week.
* Log interruptions, including email.
| Week | Deep hours | Interruptions |
| --- | --- | --- |
| 1 | 6 | 41 |
| 2 | 11 | 23 |
> The ability to concentrate is the new superpower.
```
def deep_hours(week):
return sum(week.blocks)
```

Three things to notice before reading the rest. The Identifier line is the book's UUID — noise in a corpus, delete it. The metadata title is bold text, not a # heading, so the file has no single document title. And the image is a filename that resolves to nothing once the .md leaves the zip.
The mapping, measured element by element
Every row here was checked against real output, not taken from documentation:
| In the EPUB | In the Markdown | Note |
|---|---|---|
h1 … h6 | # … ###### | Exact, no inference. A book with one h1 per chapter gives you many # and no single top-level title |
p, br | paragraphs | A line break inside a paragraph stays a soft newline, not a hard break |
strong, em, del, code | **b**, *i*, ~~s~~, `c` | Exact |
a href to the web | [text](https://…) | Exact — an improvement over PDF, where links are annotations and vanish |
a with a title | [text](url "title") | Valid Markdown, rarely rendered, often noise in the output |
Nested ul/ol | *, then +, then -, two spaces per level | Renumbering is preserved: <ol start="7"> comes out as 7. and 8. |
table with <thead> | pipe table with a header row | Exact |
table with only <tr><td> | pipe table with an empty header row | See the next section |
blockquote | > per paragraph, blank quoted line between | A two-paragraph quote stays visibly two paragraphs |
pre/code | fenced block without a language tag | The EPUB has no place to store a language, so none is invented |
img |  | Path as written in the book; the file itself is not extracted |
figure + figcaption | image line, then the caption as text | Caption survives as a separate paragraph |
dl/dt/dd | term then : definition | Pandoc definition-list syntax; GitHub and most renderers show it as plain text |
span class, abbr title | the text, without the attributes | Small caps and tooltips are presentation, and presentation is dropped |
| Typographic entities | real UTF-8 characters | … → …, — → —, non-breaking space kept |
| CSS, classes, fixed-layout positions | nothing | Text arrives in source document order — see the DRM section for what that breaks |
dc:title, dc:creator, dc:language, dc:publisher, dc:date, dc:description | bold lines at the top | Several dc:creator are joined with a comma |
dc:subject, dc:rights | dropped | Not read at all |
The empty header row, again
Word tables have no header cells and a Word-produced EPUB inherits that. Markdown requires one, so the converter emits an empty row and its separator, then your data:
| | |
| --- | --- |
| Left | Right |
| 1 | 2 |
The table renders and no cell is lost, but the first data row is no longer labelled: a reader sees two columns with no names and a retriever indexes Left as a value of an unknown field. The repair is the same as for DOCX — move the first data row into the header — and the script for it is in the DOCX guide. A pipe inside a cell breaks a row the same way it does there: a|b turns two columns into three, while asterisks and underscores are escaped for you.
Images: most of the file, none of the content
The same novel from Project Gutenberg, in its illustrated edition, is 24.8 MB — and 561 KB without images, a factor of 44. Both editions convert to the same text: 752,507 characters, 130,711 words, in about a second. The 163 illustrations become 163 links of the form , names that exist only inside the zip.
Two practical consequences. First, if all you want is text, download or produce the image-free edition: it converts to the same Markdown and uploads 44 times faster. Second, delete the image lines afterwards — they are not decoration for a model, they are tokens spent on file names. Standalone lines are easy to drop; some publishers put an illustration inside a heading, which is how a chapter title ends up as ##  Mrs Bennet and her two youngest girls. CHAPTER IX.
Links that point at files nobody has
Inside an EPUB, chapters link to each other by relative path: <a href="c2.xhtml#methods">. Markdown keeps that faithfully — [the methods section](c2.xhtml#methods) — and outside the book it is a dead link. Footnote references are the same story: a marker becomes [1](#n1), but the converter never writes an anchor with id="n1", so even inside the .md the link goes nowhere, and the footnote text sits as its own paragraph where the publisher put it.
A table of contents stored as XHTML behaves the same way: if the book has it in the spine, you get a # Contents section holding an ordered list of links to chapter files that are not there. It is worth deleting from a corpus — it competes with real content in retrieval and answers no question.
Order comes from the spine, coverage does not
Chapter order follows content.opf, not filenames. Two chapters named zz.xhtml and aa.xhtml, declared in that order, come out in that order — so a book whose files were renamed by a tool still reads correctly.
What the spine does not do is include everything. A chapter listed in the manifest but not in the spine is skipped silently: a test book with an endnote file declared only in the manifest came back without a word of it. That is the format's rule — the spine is the reading order — but it means a conversion can be complete and still not contain the notes. If the notes matter, check the spine first.
A whole book is one file
The 24 MB novel came back as a single 762,788-byte .md. That is fine as an archive and useless as a corpus: no chunker can tell where one chapter ends, the retrieval step has one document instead of sixty-one, and no editor opens it comfortably. The headings are all there — split on them and give each chapter its own file and its own metadata:
import os, re, sys
META = re.compile(r"^\*\*([A-Z][a-z]+):\*\*\s*(.*)$")
IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
def chapters_from(path, level=2):
"""Leading '**Key:** value' lines become front matter; images are dropped."""
lines = open(path, encoding="utf-8").read().split("\n")
meta, i = {}, 0
while i < len(lines) and not lines[i].startswith("#"):
m = META.match(lines[i].strip())
if m:
meta[m.group(1).lower()] = m.group(2).strip()
i += 1
body, images = [], 0
for line in lines[i:]:
images += len(IMAGE.findall(line))
line = re.sub(r"^(#{1,6})\s+", r"\1 ", IMAGE.sub("", line)).rstrip()
if line and not re.fullmatch(r"#{1,6}", line):
body.append(line)
parts, current = [], []
marker = "#" * level + " "
for line in body:
if line.startswith(marker):
if any(l.strip() for l in current):
parts.append(current)
current = [line]
else:
current.append(line)
if any(l.strip() for l in current):
parts.append(current)
return meta, parts, images
def yaml(v):
return '"' + v.replace('"', '\\"') + '"'
def main():
path, outdir = sys.argv[1], sys.argv[2]
level = int(sys.argv[3]) if len(sys.argv) > 3 else 2
os.makedirs(outdir, exist_ok=True)
meta, parts, images = chapters_from(path, level)
for n, part in enumerate(parts, 1):
title = part[0].lstrip("#").strip()
head = ["---"]
for key in ("title", "authors", "language", "date", "publisher"):
if meta.get(key):
head.append(f"{key}: {yaml(meta[key])}")
head += [f"chapter: {n}", f"section: {yaml(title)}", "---", ""]
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:50] or f"chapter-{n}"
with open(os.path.join(outdir, f"{n:02d}-{slug}.md"), "w", encoding="utf-8") as f:
f.write("\n".join(head) + "\n" + "\n".join(part) + "\n")
print(f"{path}: {len(parts)} chapter(s), {images} image(s) removed -> {outdir}")
main()
Run as python split_epub.py book.md out/ 2 — the third argument is the heading level your book uses for chapters. On the Gutenberg novel (## per chapter, one stray # in the front matter) it produced 63 files: 61 chapters plus the licence sections at both ends, the largest 29.8 KB. On a book that gives each chapter an h1 the level is 1, and the same run gives two files instead of four. Read the output of the first run before you trust the split.
DRM, fixed layout and the formats around it
A book bought with DRM is a book whose chapters are ciphertext, and no converter can undo that — nor should it. What is worth knowing is what you get: not an error, but a paragraph of garbage. A test file with encrypted chapter content and a valid content.opf converted without complaint: the metadata block was correct, and the chapter arrived as Ÿ*…garbage ciphertext. The converter does not read META-INF/encryption.xml, so it cannot tell a protected book from a corrupt one. If the text looks like noise, the file is encrypted — buy the unprotected edition or ask the publisher, do not try to clean it up.
The same file also carries a harmless case worth distinguishing: most commercial EPUBs list encryption.xml for font obfuscation only, and the text is untouched. Such a book converts perfectly, so the mere presence of that file proves nothing.
Fixed-layout EPUBs — comics, cookbooks, children's books — place every text block with absolute CSS coordinates. Since CSS is dropped, you get the blocks in source document order: two columns that sat side by side on the page become line A, line B, next page, line A, line B. Reading order survives only if the publisher wrote the file in reading order, which good ones do and bad ones do not.
Other ebook formats are a step away in Calibre, and using it is better than expecting this converter to know them: ebook-convert book.fb2 book.epub for FB2, ebook-convert book.azw3 book.epub for a Kindle file you own without DRM. Both produce a normal EPUB that converts here without surprises.
A repair pass before a model reads it
- Delete the
Identifierline and, on a public-domain book, the licence sections at both ends. - Split on the chapter heading level — the script above — so each chunk boundary is a real chapter.
- Drop image lines; the alt text is the only information they carry and often it is empty.
- Delete a
# Contentssection if the book has one in its spine: it is a list of dead links that competes with real text. - Fix empty table headers, or a table without column names is indexed as anonymous data.
- Keep one
#per file: after splitting, that is the chapter, and##is the section. - Put the metadata into front matter (the script does it) so the vector payload carries title, author and year instead of them sitting as bold text in the first chunk.
Privacy, limits and the API
The book is parsed on our server: a temporary copy exists for the duration of the conversion and is deleted straight after it. Nothing is sent to a third-party service, file contents never reach analytics — only an event name and a hashed IP — and there is an on-prem install inside your own network for libraries that cannot leave the building.
Free tier: 5 files a day from one IP, up to 25 MB each, no account. The illustrated novel above is 24.8 MB and fits, barely; the image-free edition is 561 KB and converts to the same text. Pro (300 MB, no daily cap) is not on sale yet — we are not taking money for it; leave a request on the pricing page. For pipelines there is POST /api/convert, which returns JSON with a markdown field and the book's title, plus POST /api/convert/batch and POST /api/convert/zip, which returns one .md per uploaded file — for a book, that is one file, not one per chapter. Working examples are in the API documentation.