LlmFiles
Converter to Markdown
LlmFiles/Guides/EPUB to Markdown

EPUB to Markdown

A book is the best-behaved input a converter can get: an EPUB is a zip of XHTML files with real h1 and h2 tags, so nothing has to be guessed from font sizes. What goes wrong is different, and this page documents it on a 24 MB novel rather than on a promise: the empty table header, images that arrive as filenames, footnote links that point nowhere, and one 750,000-character file where you expected chapters.

Updated 25 Oct 2026 · LlmFiles

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

![Graph of deep work hours per week](fig1.png)

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 EPUBIn the MarkdownNote
h1 … h6# … ######Exact, no inference. A book with one h1 per chapter gives you many # and no single top-level title
p, brparagraphsA 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 levelRenumbering is preserved: <ol start="7"> comes out as 7. and 8.
table with <thead>pipe table with a header rowExact
table with only <tr><td>pipe table with an empty header rowSee the next section
blockquote> per paragraph, blank quoted line betweenA two-paragraph quote stays visibly two paragraphs
pre/codefenced block without a language tagThe EPUB has no place to store a language, so none is invented
img![alt](images/fig2.png)Path as written in the book; the file itself is not extracted
figure + figcaptionimage line, then the caption as textCaption survives as a separate paragraph
dl/dt/ddterm then : definitionPandoc definition-list syntax; GitHub and most renderers show it as plain text
span class, abbr titlethe text, without the attributesSmall caps and tooltips are presentation, and presentation is dropped
Typographic entitiesreal UTF-8 characters&hellip; → …, &mdash; → —, non-breaking space kept
CSS, classes, fixed-layout positionsnothingText arrives in source document order — see the DRM section for what that breaks
dc:title, dc:creator, dc:language, dc:publisher, dc:date, dc:descriptionbold lines at the topSeveral dc:creator are joined with a comma
dc:subject, dc:rightsdroppedNot 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 ![](3878325469831718223_i_026.jpg), 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 ## ![](…_i_082_a.jpg) 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.

Delete the first and last section of a Gutenberg book: the licence is long, appears in every copy of the same title, and it is excellent at matching a query about rights and useless at answering anything else.

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

  1. Delete the Identifier line and, on a public-domain book, the licence sections at both ends.
  2. Split on the chapter heading level — the script above — so each chunk boundary is a real chapter.
  3. Drop image lines; the alt text is the only information they carry and often it is empty.
  4. Delete a # Contents section if the book has one in its spine: it is a list of dead links that competes with real text.
  5. Fix empty table headers, or a table without column names is indexed as anonymous data.
  6. Keep one # per file: after splitting, that is the chapter, and ## is the section.
  7. 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.

Frequently asked questions

Does a converted book come out as one file or as chapters?

One file: every spine item is joined into a single Markdown document, in reading order. Chapter headings are preserved, so splitting afterwards is a five-line regex — the script on this page turned a 24 MB novel into 63 chapter files.

Are the illustrations extracted?

No. Each image becomes a Markdown link with the path it had inside the zip, so the reference points at a file that is not in your download. The alt text and the caption survive as text. Unzip the .epub and take the files from OEBPS/images/ if you need the pictures.

Why does my table start with an empty row?

Because the table has no <thead> — common in books converted from Word. Markdown needs a header row, so an empty one is emitted. Move the first data row up, or fix the source; the DOCX guide has the script.

Can it convert a DRM-protected book?

No, and it will not tell you so: the encrypted chapter arrives as garbage text while the metadata block looks perfectly normal. We do not strip DRM. Font obfuscation is a different matter and does not affect the text at all.

What happens to footnotes?

The marker becomes a link like [1](#n1), but no anchor with that id is written, so the link is dead even inside the Markdown. The footnote text stays as a paragraph where the publisher put it — usually the end of the chapter, sometimes the end of the book.

Do I need the EPUB or the no-images edition?

For text, either: both convert to the same Markdown. The image-free file is far faster to upload — 561 KB against 24.8 MB for the same novel — and on a free plan it is the difference between a comfortable margin and none.

What are the limits, and can this run in a pipeline?

Free: 5 files a day from one IP, up to 25 MB each, no sign-up. Pro is not being sold yet. Automation works via POST /api/convert, /api/convert/batch and /api/convert/zip.

Convert a file

Drag a document into the window and get Markdown you can read, diff and feed to a model. Free: 5 files a day, up to 25 MB.