Parsing and Rebuilding EPUB Files in Python: Lessons Learned from Building an AI Book Translator
How we used ebooklib and lxml to extract, translate, and reconstruct EPUBs while preserving formatting and structure. We started building LectuLibre, an AI-powered book translation service, with a simple premise: users upload an EPUB or PDF, we translate it using large language models (Claude, Deep

How we used ebooklib and lxml to extract, translate, and reconstruct EPUBs while preserving formatting and structure.
We started building LectuLibre, an AI-powered book translation service, with a simple premise: users upload an EPUB or PDF, we translate it using large language models (Claude, DeepSeek), and they download a translated copy. Sounds straightforward—but the devil is in the details, especially when you promise to preserve the original formatting, fonts, images, and structure. Here’s what we learned about parsing and rebuilding EPUB files in Python, and how we solved the trickiest parts.
EPUB is essentially a ZIP archive containing XHTML files, CSS, images, and metadata. To translate a book, we needed to:
Naively, you might think you can just unzip, regex-replace the text, and zip it back. But that breaks as soon as you encounter nested tags, split sentences across tags, or special characters. We needed a proper parser.
We settled on ebooklib for the container-level work and lxml for parsing and modifying the XHTML content. This combination gave us the best balance of convenience and control.
Here's a minimal example of loading an EPUB and iterating over its spine items (documents in reading order):
from ebooklib import epub from lxml import etree book = epub. read_epub ( ' input.epub ' ) for item in book. get_items_of_type ( epub. ITEM_DOCUMENT ): # item.content is bytes content = item. content. decode ( ' utf-8 ' ) tree = etree. fromstring ( content. encode ( ' utf-8 ' )) # Now work with tree... Enter fullscreen mode Exit fullscreen mode But there's a catch: namespaces. XHTML files declare a default namespace ( xmlns="http://www.w3.org/1999/xhtml" ), and lxml requires you to handle that. If you just do tree.xpath('//p'), you'll get nothing. You need to map the namespace.
ns = { ' xhtml ': ' http://www.w3.org/1999/xhtml ' } paragraphs = tree. xpath ( ' //xhtml:p ', namespaces = ns ) Enter fullscreen mode Exit fullscreen mode This was our first big "gotcha". We wrote a helper to strip namespaces or always use the namespace map.
For translation, we needed to extract text chunks that made sense. Instead of grabbing all text nodes indiscriminately (which would flatten everything and lose structure), we walked the DOM and collected text per block-level element (p, h1, h2, etc.) while preserving inline tags.
Here's a simplified version of our extraction function:
def extract_paragraphs ( tree ): ns = { ' x ': ' http://www.w3.org/1999/xhtml ' } blocks = [] for elem in tree. xpath ( ' //x:p | //x:h1 | //x:h2 | //x:h3 | //x:h4 | //x:h5 | //x:h6 ', namespaces = ns ): text = elem. text_content (). strip () if text: blocks. append ( text ) return blocks Enter fullscreen mode Exit fullscreen mode But this flattened inline tags—losing <em> or <span> information. For translation, we actually wanted to preserve those tags because they carry meaning (emphasis, quotes, etc.). So we devised a more granular approach: we serialized the inner content of each block element to a compact markup string, sent that to the LLM with instructions to translate text but keep tags, and then parsed the result back.
That was complex and error-prone, so for v1 we settled on a simpler method: we translate the plain text of each paragraph, then insert the translated text into the original block element, stripping all inline tags. This meant losing italics/bold in translated text, which was acceptable for MVP but we knew we'd need to improve later. It's a trade-off we documented clearly.
To replace text while preserving the block-level structure, we used lxml to set the text content of each block element. For a paragraph <p>Hello <em>world</em>!</p>, if we want to replace with "Bonjour le monde!", we could do:
new_p = etree. SubElement ( parent, ' {http://www.w3.org/1999/xhtml}p ' ) new_p. text = translated_text # Replace old_p with new_p old_p. getparent (). replace ( old_p, new_p ) Enter fullscreen mode Exit fullscreen mode But that removes any attributes or classes on the original paragraph. Better approach: keep the element, but remove all children and set.text:
for child in old_p: old_p. remove ( child ) old_p. text = translated_text Enter fullscreen mode Exit fullscreen mode This preserves the tag, attributes, and tail text (though tail text needed careful handling; we often used etree.strip_elements or manual tail merging).
In our pipeline, we don't translate paragraph by paragraph; that would be too slow and lose context. Instead, we extract all paragraphs from the entire book (in spine order), then send them to the LLM in batches (e.g., 50 paragraphs per request). The LLM returns translated paragraphs in the same order, and we map them back.
Here's how we apply the translations back to the document tree:
def replace_paragraphs ( tree, translated_texts ): ns = { ' x ': ' http://www.w3.org/1999/xhtml ' } blocks = tree. xpath ( ' //x:p | //x:h1 | //x:h2 | //x:h3 | //x:h4 | //x:h5 | //x:h6 ', namespaces = ns ) for elem, new_text in zip ( blocks, translated_texts ): # Remove all children (inline tags) but keep the element itself for child in elem: elem. remove ( child ) elem. text = new_text return tree Enter fullscreen mode Exit fullscreen mode We keep a global list of all block elements across all XHTML files, in order, and after translation, we iterate over the files again and update each element with the corresponding translated text. This ensures the reading order is preserved.
Images and CSS are separate EPUB items. We didn't need to modify them, just make sure they are preserved when rebuilding. ebooklib does this automatically: when you read an EPUB, all items are stored in the book object, and when you write, they are included. However, we had to ensure that the items we modified (the XHTML documents) maintained their correct names and paths.
One issue: when we modified an item's content, we had to set it back:
item. content = etree. tostring ( tree, encoding = ' utf-8 ', xml_declaration = True ) Enter fullscreen mode Exit fullscreen mode We also had to be careful about the xml:lang attribute and the DOCTYPE declaration. ebooklib sometimes added or removed them inconsistently. We ended up post-processing the serialized string to fix it.
EPUB3 uses a nav.xhtml file for the table of contents. ebooklib has a book.toc property, but it's often out of sync or not updated when you change document structure. Since we weren't changing the structure (just text inside paragraphs), we didn't need to modify the TOC. But for some books, we had to add language metadata or update the title.
book. set_title ( ' Translated Title ' ) book. set_language ( ' fr ' ) book. add_metadata ( ' DC ', ' description ', ' Translated by LectuLibre ' ) Enter fullscreen mode Exit fullscreen mode Performance Numbers For a typical 300-page novel (~100,000 words), our parsing and rebuilding pipeline (excluding LLM translation time) takes about 2-3 seconds on a standard VPS. The bottleneck is actually the epub.read_epub and epub.write_epub operations, which involve ZIP compression/decompression. The lxml DOM operations are fast (milliseconds per file). Since translation via LLM takes minutes, the overhead is negligible.
How do you handle translation of text with inline formatting (bold, italics, links) without losing the semantics? We've experimented with sending serialized HTML to the LLM and asking it to return translated HTML, but reliability is hit-or-miss. If you've solved this elegantly, we'd love to hear about it.