DEV Community

龚旭东
龚旭东

Posted on

Parsing and Rebuilding EPUB Files in Python: Lessons Learned

How we built a robust EPUB translation pipeline by ditching EbookLib for zipfile and lxml

At LectuLibre, we translate books using LLMs. Our input is EPUB files, our output should be a translated EPUB that looks like the original, just in another language. That means we need to parse the EPUB, extract the text, send it to Claude or DeepSeek, get back the translation, and then rebuild the EPUB with the translated text while preserving all formatting, images, styles, and navigation.

This sounds straightforward until you realize that EPUB is a messy format: it's a ZIP archive containing XHTML files, CSS, images, fonts, and an XML navigation document. And the XHTML inside is often not well-formed XML — publishers use all sorts of quirks. We learned a lot while building this pipeline. Here are the lessons.

The naive approach: EbookLib

We started with EbookLib, a popular Python library for reading and writing EPUB files. It abstracts away the ZIP and gives you objects for each item.

import ebooklib
from ebooklib import epub

book = epub.read_epub('book.epub')
for item in book.get_items():
    if item.get_type() == ebooklib.ITEM_DOCUMENT:
        print(item.get_name())
Enter fullscreen mode Exit fullscreen mode

Extracting text was easy enough. We could get the HTML content of each chapter and use BeautifulSoup or lxml to extract paragraphs. But when it came time to write the translated book back out, EbookLib started to fight us.

The main problem: EbookLib rebuilds the EPUB from its internal model, and that model doesn't capture every nuance. We lost CSS links, some metadata, and the exact ordering of the spine. We tried to manually set the spine, add CSS items, and copy over metadata, but it was a game of whack-a-mole. We'd fix one book and break another.

After a week of frustration, we realized we didn't need to rebuild the EPUB from scratch. We only needed to replace the text inside the existing HTML files. The rest of the EPUB (CSS, images, fonts, container.xml, etc.) could stay exactly as it was. So we went lower-level.

Treating EPUB as a ZIP archive

An EPUB is just a ZIP file with a specific structure. The META-INF/container.xml points to the OPF file, which lists all the content documents. But we don't even need to parse the OPF if we simply want to replace text in all HTML files. We can open the EPUB with Python's built-in zipfile, iterate over the entries, find the .xhtml or .html files (and sometimes .htm), and replace their contents.

import zipfile
from lxml import etree

def translate_epub(input_path, output_path):
    with zipfile.ZipFile(input_path, 'r') as zin:
        with zipfile.ZipFile(output_path, 'w') as zout:
            for item in zin.infolist():
                data = zin.read(item.filename)
                if item.filename.endswith(('.xhtml', '.html', '.htm')):
                    translated_html = translate_html(data.decode('utf-8'))
                    data = translated_html.encode('utf-8')
                zout.writestr(item, data)
Enter fullscreen mode Exit fullscreen mode

This approach gives us full control. We don't alter any other part of the EPUB, so all the original assets remain untouched. But we need to be careful: some HTML files may not be UTF-8 encoded (they might have a different encoding declared in the XML prolog). We'll handle that later.

Translating HTML without breaking structure

Now the core challenge: given an HTML string, how do we replace the text with a translated version while keeping all the inline formatting (spans, emphasis, links, etc.)?

Naively, you might do a simple string replacement: html.replace(original_text, translated_text). That fails when the text is split across multiple tags, or when the translation length changes, or when there are HTML entities. A better way is to parse the HTML into a DOM tree, extract the text from each block-level element, translate each block independently, then replace the text content of those blocks.

We use lxml with its HTML parser because it can handle malformed HTML gracefully (unlike the stricter XML parser).

from lxml import etree

def extract_paragraphs(html_content):
    parser = etree.HTMLParser()
    tree = etree.fromstring(html_content, parser)
    blocks = tree.xpath('//p | //h1 | //h2 | //h3 | //h4 | //h5 | //h6 | //li | //blockquote')
    result = []
    for block in blocks:
        # Get the full text of the block, including text from child elements
        text = block.text_content().strip()
        if text:
            result.append(text)
    return result
Enter fullscreen mode Exit fullscreen mode

We collect all block-level texts, send them as a batch to the translation API (to preserve context), get back a list of translated strings, and then map them back to the original blocks.

def replace_block_texts(html_content, translations):
    parser = etree.HTMLParser()
    tree = etree.fromstring(html_content, parser)
    blocks = tree.xpath('//p | //h1 | //h2 | //h3 | //h4 | //h5 | //h6 | //li | //blockquote')
    # Assume translations is a list of strings in the same order as blocks
    for block, new_text in zip(blocks, translations):
        # Remove all child elements but keep the block element itself
        for child in block:
            block.remove(child)
        # Set the text of the block (lxml will escape special characters automatically)
        block.text = new_text
    return etree.tostring(tree, encoding='unicode', method='html')
Enter fullscreen mode Exit fullscreen mode

This approach preserves the element's attributes (class, id, style) but discards any inline formatting within the block. That means if the original paragraph had <em> for emphasis or <a> for links, those are lost. For our first version, that was acceptable because we focus on the text, but we later improved it by splitting translation at the phrase level while keeping inline tags. That's a topic for another article.

Encoding and special characters

EPUB files may be encoded in UTF-8 or UTF-16, or even declared as ISO-8859-1. We initially read everything as UTF-8, which caused crashes on some books. The solution is to respect the encoding declaration in the HTML file.

We detect the encoding by looking at the first line of the file (the <?xml ... encoding="..."?> declaration) or by using chardet if no declaration is present. Then we decode accordingly, and when writing back, we always encode as UTF-8 and update the XML declaration to match.

import re

def decode_html(data: bytes) -> str:
    # Try to find encoding in the first 200 bytes
    head = data[:200].decode('ascii', errors='ignore')
    match = re.search(r'encoding=["\']([^"\']+)["\']', head)
    if match:
        encoding = match.group(1)
    else:
        # Fallback to chardet
        import chardet
        detected = chardet.detect(data)
        encoding = detected['encoding'] or 'utf-8'
    return data.decode(encoding, errors='replace')
Enter fullscreen mode Exit fullscreen mode

When writing back, we ensure the encoding attribute in the XML prolog is set to utf-8.

Performance: Memory and speed

Our first iteration loaded the entire EPUB into memory, processed all files, and wrote the output. For small books (under 5MB), this was fine. But we soon got a user upload of a 300MB EPUB (lots of images and a huge text). Our server (a modest VPS with 4GB RAM) crashed with an out-of-memory error.

We fixed this by streaming: read one ZIP entry at a time, process it, and write it immediately to the output ZIP. Since we aren't doing any cross-file operations, this works perfectly. The memory usage dropped from over 1GB to about 200MB for that large book, and processing time stayed roughly the same (around 1.2 seconds for a typical 500KB novel, 45 seconds for the 300MB monster) because the bottleneck is the translation API, not file I/O.

Validation: epubcheck and internal links

After rebuilding, we run epubcheck (a Java-based validator) on the output to ensure it's a valid EPUB. Our initial naive replacement with EbookLib had a pass rate of around 60%. With the ZIP-and-lxml approach, we now pass 98% of the time. The remaining 2% are usually books with unusual structures (fixed-layout, JavaScript, or malformed OPF files) that we flag for manual review.

We also added a check for internal links: some XHTML files link to other files via href. Since we are not changing file names or locations, these links remain valid. But if we ever need to reorder or rename files, we'd have to update those links.

Lessons learned

  1. EbookLib is good for reading, not for writing. If you only need to extract data, use it. For rebuilding, go lower-level.
  2. Use lxml with the HTML parser. It handles the messy real-world HTML found in EPUBs.
  3. Treat the EPUB as a ZIP and only modify what you need. Don't try to reconstruct the whole package; just swap the text content.
  4. Respect encodings. EPUBs can be in various charsets; don't assume UTF-8.
  5. Stream when possible. Don't hold the whole book in memory.
  6. Validate with epubcheck. It catches many subtle issues.

We're still improving the pipeline. The next challenge is handling inline formatting without losing emphasis or links. We're experimenting with translating at the sentence level and mapping back to the original inline tags. If you have experience with that, we'd love to hear from you.

Until next time, happy coding!

Top comments (0)