How we handle complex EPUB structures, preserve metadata, and rebuild valid files after AI translation.
At LectuLibre, we translate books using LLMs. Users upload an EPUB, our backend extracts the text, sends it to Claude or DeepSeek, and then we need to put the translated text back into the same EPUB—preserving formatting, images, styles, and metadata. That last part turned out to be the hardest.
In this post, I'll walk you through the challenges we faced with parsing and rebuilding EPUB files in Python, the libraries we chose, and the trade-offs we made. If you're building any kind of ebook processing pipeline, these lessons should save you time.
The Problem: EPUB Is More Than Just HTML
EPUB files are ZIP archives containing XHTML/HTML documents, CSS, images, fonts, and metadata files like container.xml and content.opf. To translate a book, we need to:
- Extract the textual content from each chapter.
- Keep track of where that text came from so we can put translations back in the same place.
- Send the text to an LLM (in chunks, because books are long).
- Rebuild the EPUB with translated text while preserving all other resources.
Initially, we thought we could just unzip, parse HTML with BeautifulSoup, translate, and zip it back. But we quickly ran into issues:
-
EPUB structure varies: Some books use EPUB2 with
NCXnavigation, others EPUB3 withnavdocuments. Metadata is in different places. -
Namespaces matter: XHTML files often use
xmlns="http://www.w3.org/1999/xhtml", and if you don't handle namespaces correctly, BeautifulSoup can mangle the markup. - Internal references: The OPF manifest lists all files with IDs and properties. If you rename or add files, you must update the manifest.
- Encoding and special characters: Books in different languages use various encodings, and LLM outputs may contain smart quotes or special characters that need to be handled correctly.
Our Approach: ebooklib + BeautifulSoup
We evaluated several Python libraries:
-
zipfile+ manual XML parsing: too low-level, easy to break things. -
lxmldirectly: powerful but requires deep understanding of EPUB spec. -
ebooklib: a dedicated EPUB library that handles reading/writing, metadata, and manifest management. -
epub-parserand others: less maintained or incomplete.
We ended up using ebooklib for the EPUB container operations (reading, writing, navigating items) and BeautifulSoup for fine-grained HTML manipulation inside chapters. This combination gave us the best balance of simplicity and control.
Implementation Details
Reading an EPUB and Extracting Text
Here's a simplified version of how we load an EPUB and iterate over its document items:
from ebooklib import epub
from bs4 import BeautifulSoup
def extract_chapters(epub_path):
book = epub.read_epub(epub_path)
chapters = []
for item in book.get_items():
if item.get_type() == ebooklib.ITEM_DOCUMENT:
# item is an XHTML/HTML document
soup = BeautifulSoup(item.get_content(), 'html.parser')
# Extract text from all paragraphs
text = '\n\n'.join(p.get_text() for p in soup.find_all('p'))
chapters.append({
'id': item.get_id(),
'file_name': item.get_name(),
'content': text,
'soup': soup # keep soup for later modification
})
return book, chapters
We store the soup object because we'll need to modify it during rebuilding.
Translating and Rebuilding
After sending the extracted text to the LLM and receiving translations, we need to put the translated text back into the original HTML structure. Our approach splits the original text into paragraphs and replaces each paragraph's text with the translated version, preserving tags and attributes.
from bs4 import BeautifulSoup
def replace_text_in_soup(soup, original_paragraphs, translated_paragraphs):
# Find all <p> tags
p_tags = soup.find_all('p')
if len(p_tags) != len(translated_paragraphs):
raise ValueError("Mismatch in paragraph count")
for p_tag, translated_text in zip(p_tags, translated_paragraphs):
# Clear existing content and set new text
p_tag.clear()
p_tag.append(translated_text)
return soup
Then we write the modified soup back to the ebooklib item and save the EPUB:
import ebooklib
from ebooklib import epub
def rebuild_epub(book, chapters_with_new_text):
for chapter in chapters_with_new_text:
item = book.get_item_with_id(chapter['id'])
# Convert soup back to bytes
new_content = str(chapter['soup']).encode('utf-8')
item.set_content(new_content)
# Write to a new EPUB file
epub.write_epub('translated_book.epub', book)
That's the core loop. But real-world books threw many curveballs.
Lessons Learned: Handling Edge Cases
Namespaces
EPUB XHTML files often declare the XHTML namespace. If you use BeautifulSoup's html.parser, it may treat tags as HTML and ignore namespaces, which can lead to issues when serializing. We switched to lxml parser with BeautifulSoup:
soup = BeautifulSoup(content, 'lxml')
This parser is namespace-aware and better preserves the original markup. However, it's slightly slower. For a 500-page book, parsing all chapters with lxml took about 1.2 seconds vs 0.8 seconds with html.parser—a negligible difference compared to the LLM translation time.
Handling Non-Paragraph Content
Not all text is in <p> tags. Headings, list items, tables, and footnotes need translation too. Our initial paragraph-only extraction missed a lot. We expanded extraction to include all text-bearing elements:
def extract_text_elements(soup):
elements = []
for tag in soup.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td', 'th']):
if tag.get_text(strip=True):
elements.append(tag)
return elements
Then we translate each element's text and replace it individually. This works but can disrupt inline formatting like bold or italic within paragraphs. To handle that, we need to translate the entire HTML subtree, not just the text—but that's a much harder problem. For our MVP, we translate per element and accept that some inline formatting may be lost.
CSS and Layout
EPUBs contain CSS files that control layout. We don't modify these, so the translated book looks similar to the original, assuming the structure is intact. However, some languages expand or contract text significantly. For example, German translations are often 30% longer than English. This can cause pagination issues, but that's beyond our current scope.
Cover and Metadata
We preserve the cover image and metadata by using ebooklib's built-in methods. We read the original metadata and write it back unchanged:
# Preserve metadata
original_metadata = book.get_metadata('DC', 'title')
# ... after rebuild, set same metadata
book.set_metadata('DC', 'title', original_metadata[0][0])
Ebooklib handles the OPF manifest automatically when we use its methods, which saved us from manually editing XML.
Malformed EPUBs
Some user uploads are not valid EPUBs; they might be mislabeled or corrupted. We added validation:
import ebooklib
from ebooklib import epub
def validate_epub(path):
try:
book = epub.read_epub(path)
if not book.get_items():
return False
# Check for required files
if not book.get_item_with_id('nav') and not book.get_item_with_id('ncx'):
# EPUB2 uses NCX, EPUB3 uses nav
return False
return True
except Exception:
return False
If validation fails, we reject the file with a clear error message.
Performance Numbers
On our VPS (2 vCPU, 4GB RAM), parsing a typical 300-page EPUB with 20 chapters takes about 0.5 seconds using lxml. Extracting all text elements and sending them to the LLM (in chunks) dominates the total processing time—often minutes. Rebuilding the EPUB is fast: under 1 second.
Memory usage is modest because we process chapter by chapter, not the whole book at once. For very large books (1000+ pages), we still keep the entire soup tree in memory per chapter, which can spike to ~50MB per chapter, but it's manageable.
What We'd Do Differently
-
Use lxml directly for HTML parsing: BeautifulSoup's overhead isn't huge, but if we need finer control over inline elements, we might switch to direct
lxml.etreewith custom translation logic that preserves markup. -
Translate at the block level with inline markup awareness: We'd like to translate each block element while keeping inline tags like
<em>,<strong>, and<a>intact. This requires more sophisticated text extraction and reinsertion—possibly using placeholders. - Handle EPUB3 fixed-layout books: These are common for children's books and comics, and they use absolute positioning. Our current approach does not support them.
Conclusion
Parsing and rebuilding EPUBs in Python is doable with the right libraries, but the devil is in the details. ebooklib handles the EPUB container well, and BeautifulSoup with lxml gives you the HTML manipulation power you need. The main challenges are preserving structure, handling namespaces, and dealing with the variety of real-world EPUBs.
If you're building ebook processing tools, my advice is to start with a simple pipeline like the one above, then iterate as you encounter edge cases. Validate early and often.
Open question for the community: How do you handle inline formatting (bold/italic/links) when translating HTML content? We've tried placeholder approaches but are curious about other solutions.
LectuLibre is an AI-powered book translation service. We're constantly improving our EPUB handling to support more formats and languages. If you have experience with ebook pipelines, we'd love to hear from you in the comments.
Top comments (0)