Parsing and Rebuilding EPUB Files in Python: Lessons Learned from Building an AI Translation Service LectuLibre built a service that translates entire EPUB books using large language models. The team developed a Python pipeline that parses EPUB files, extracts text while preserving formatting, sends it to an LLM for translation, and reconstructs the EPUB. They used ebooklib for high-level structure and lxml for precise XML control to handle real-world EPUB complexities. How we extract, translate, and reconstruct entire ebooks with Python while preserving every detail At LectuLibre, we built a service that translates entire books using large language models. Our users upload EPUB files, and our backend pipeline parses them, extracts the text, sends it to an LLM for translation, and then rebuilds the EPUB with the translated content—all while preserving the original formatting, images, and metadata. This sounded straightforward until we looked inside a real EPUB. EPUB is essentially a ZIP file containing a structured set of XHTML, CSS, and XML files. The content.opf file defines the reading order spine , metadata, and manifest. The toc.ncx holds the table of contents. The actual text lives in XHTML documents, often split per chapter. To translate a book, we needed to: 1 reliably parse the EPUB, 2 locate all translatable text, 3 send it chunk by chunk to the LLM, and 4 rebuild the EPUB with the translated text while keeping every byte of the formatting intact. We initially reached for ebooklib , the most popular Python library for EPUB manipulation. It worked great for simple EPUBs—until we threw a few hundred real-world files at it. We quickly hit issues: ebooklib didn’t fully preserve custom metadata or namespace-prefixed properties in the OPF. xmlns attributes, breaking rendering on some devices. ebooklib loaded everything at once.We could have used a heavyweight tool like Calibre’s command-line interface, but that introduced external dependencies and wasn’t as programmatically flexible. Instead, we decided to stick with ebooklib for high-level book structure and augment it with lxml for precise XML control. Here’s the core approach we landed on: ebooklib to get a list of items documents, images, CSS . ITEM DOCUMENT XHTML and sometimes ITEM NAVIGATION NCX for titles . lxml , extract text, while keeping a map of each text node to its parent element. ebooklib , manually ensuring the OPF and spine are correct.Let’s dive into the code. python import ebooklib from ebooklib import epub book = epub.read epub 'original.epub' translatable items = for item in book.get items : if item.get type == ebooklib.ITEM DOCUMENT: translatable items.append item Some books use NCX for chapter titles elif item.get type == ebooklib.ITEM NAVIGATION: translatable items.append item We ignore images, fonts, and CSS—they don’t contain translatable text. We need to extract text while remembering exactly where it came from. We use lxml.etree to parse the XHTML and walk the tree, collecting text nodes and their XPath locations: python from lxml import etree def extract text with xpath content : parser = etree.HTMLParser root = etree.fromstring content, parser tree = etree.ElementTree root text mapping = list of xpath, original text, parent element for elem in root.iter : if elem.text and elem.text.strip : xpath = tree.getpath elem text mapping.append xpath, elem.text, elem if elem.tail and elem.tail.strip : tail text belongs to the parent, but logically follows the element parent = elem.getparent xpath = tree.getpath parent if parent is not None else None if xpath: text mapping.append xpath, elem.tail, elem return text mapping Pay attention to tail text—it’s the text that follows a closing tag, common in interleaved markup. Missing it leads to lost sentences. We batch the collected text nodes into chunks that respect LLM token limits. For instance, we group consecutive text from the same XHTML document, aiming for ~3000 tokens per batch. We then send each chunk to our translation model e.g., Claude 3.5 Sonnet and receive a block of translated text. We split the translated block back into individual strings by comparing lengths advanced: we use a diff algorithm to align original and translated sentences . This is simplified here for brevity. Now we map translations back: for xpath, original, elem , translated text in zip text mapping, translations : Use xpath to locate the element again parsed fresh from original but we cached the element objects, so we can just update them if elem.text and elem.text == original: elem.text = translated text elif elem.tail and elem.tail == original: elem.tail = translated text Serialize back to string new content = etree.tostring root, encoding='unicode', method='html' We return the modified XHTML as a string, ready to replace the item’s content in the EPUB. Here’s where ebooklib shines. We create a new EpubBook , set the same metadata title, author, language , and add items: new book = epub.EpubBook new book.set identifier original book.get metadata 'DC', 'identifier' 0 0 new book.set title original book.get metadata 'DC', 'title' 0 0 new book.set language original book.get metadata 'DC', 'language' 0 0 Add all original items, replacing document content where needed for item in original book.get items : if item.get name in modified content map: Replace with translated XHTML new content = modified content map item.get name new item = epub.EpubItem uid=item.get id , file name=item.get name , media type=item.get type , content=new content.encode 'utf-8' else: Copy image, CSS, etc. as-is new item = item new book.add item new item Replicate the spine and table of contents new book.spine = original book.spine new book.toc = original book.toc Write out epub.write epub 'translated.epub', new book, {} But wait—this naive approach can corrupt the OPF. We found that ebooklib sometimes rewrites the spine order incorrectly if the original had complex nesting. To fix this, we manually post-process the written EPUB’s content.opf using lxml : python import zipfile from lxml import etree Open the new EPUB as a ZIP with zipfile.ZipFile 'translated.epub', 'a' as zf: with zf.open 'content.opf', 'r' as f: opf = etree.parse f Ensure itemref order matches original spine spine = opf.find './/{http://www.idpf.org/2007/opf}spine' Reorder based on original spine list ... custom correction logic ... zf.writestr 'content.opf', etree.tostring opf, xml declaration=True, encoding='UTF-8' Yes, it’s ugly, but it saved us from countless validation errors. We benchmarked on a typical novel: 50 chapters, 350KB uncompressed. Parsing and extracting text: ~0.2 seconds. Rebuilding after translation: ~0.3 seconds. The LLM translation step dominates around 45 seconds for the whole book , so we worked on parallelism for that part instead. However, with larger educational texts containing hundreds of images and complex tables, memory usage spiked to over 500MB. We mitigated this by processing documents one by one and releasing them immediately. xmlns="http://www.w3.org/1999/xhtml" and any custom namespaces on the