[控场AI]
· 7 分钟阅读· 3,741 字

用Python解析与重建EPUB:AI翻译书的实战经验

用Python解析与重建EPUB:AI翻译书的实战经验

基于LectuLibre项目,分享用Python处理EPUB翻译时保持格式不变的技术方案与踩坑经验。

本文基于LectuLibre项目的实战经验,探讨了构建AI图书翻译服务时用Python处理EPUB文件的核心挑战与解决方案。文章指出,真正的难点在于翻译过程中保持原书的格式、字体、图片和结构完全不变。作者对比了多种工具后选择ebooklib管理EPUB容器、lxml解析XHTML内容,并详细分析了命名空间陷阱、文本提取与替换的权衡、批量翻译流水线设计、重建时的细节问题,以及现实世界中五类硬骨头(命名空间、自闭合标签、编码、固定版式、损坏EPUB)。最后总结了核心经验,并指出保留内联标签语义仍是开放难题。

构建一个AI图书翻译服务,听起来像是个直截了当的任务:用户上传EPUB,调用大模型翻译,然后下载译版。但真正动手后才会发现,真正的挑战在于在翻译过程中保持原书的格式、字体、图片和结构完全不变。本文基于LectuLibre项目的实战经验,分享用Python处理EPUB文件时踩过的坑和总结出的方案。

为什么EPUB处理比想象中复杂

EPUB本质上是一个ZIP压缩包,里面装着XHTML文件、CSS样式、图片和元数据。要翻译一本书,需要完成四步:按阅读顺序从每个XHTML文件中提取文本、将文本分块发送给LLM翻译、用译文替换原文并保留DOM结构和内联标签、最后带着更新后的元数据、导航和所有资源重建EPUB。

一个天真的想法是:解压、正则替换文本、再压缩回去。但一旦遇到嵌套标签、跨标签断句或特殊字符,这种方法立刻崩溃。必须使用真正的解析器。

EPUB解析流程示意

工具选型:ebooklib + lxml 的组合

作者评估了多个方案。ebooklib 是高层库,负责ZIP容器、manifest、spine和目录,但本身不解析HTML内容;epubfile 维护不足;Calibre的 ebook-convert 功能强大但面向命令行,不适合嵌入Web服务;直接用 zipfile + lxml 虽然完全可控,却要自己实现EPUB规范。

最终的选择是 ebooklib 管容器,lxml 管内容,在便利性和控制力之间取得了最佳平衡。

EPUB 规范简述:EPUB 本质上是一个遵循 OCF(Open Container Format)规范的 ZIP 包,其中必须包含 mimetype 文件(内容为 application/epub+zip,且不压缩)、META-INF/container.xml(指向 OPF 包文件)以及 OPF 文件(定义 manifest、spine 和元数据)。manifest 列出所有资源(XHTML、CSS、图片等)及其 ID 和路径;spine 则定义阅读顺序,通过 itemref 引用 manifest 中的 ID。此外,EPUB 3 还包含导航文档(Nav)和可选的 NCX(用于 EPUB 2 兼容)。理解这些结构是使用 ebooklib 的基础:ebooklib 的 EpubBook 对象自动解析 container.xml 和 OPF,暴露 book.spine、book.get_items() 等接口,从而避免手动处理 ZIP 和 XML 的繁琐工作。

读取内容时遇到的命名空间陷阱

加载EPUB并按spine顺序遍历文档并不难,item.content 返回字节串,交给 lxml.etree.fromstring 即可。但第一个大坑立刻出现:XHTML文件声明了默认命名空间 xmlns="http://www.w3.org/1999/xhtml",如果直接写 tree.xpath('//p'),结果为空。必须显式映射命名空间:

ns = {'xhtml': 'http://www.w3.org/1999/xhtml'}
paragraphs = tree.xpath('//xhtml:p', namespaces=ns)

作者为此写了一个辅助函数,或者始终携带命名空间映射。这是整个项目中最先暴露的“坑”。

XHTML 与 XML 命名空间:XHTML 是 XML 的一种应用,其根元素通常带有默认命名空间声明 xmlns="http://www.w3.org/1999/xhtml"。这意味着所有未加前缀的元素(如 <p>、<div>)都属于该命名空间。XPath 1.0 在查询时,无前缀的名称默认匹配“无命名空间”的元素,因此 //p 无法匹配到任何节点。必须为 XPath 引擎提供命名空间映射,例如使用 namespaces={'xhtml': 'http://www.w3.org/1999/xhtml'} 并查询 //xhtml:p。同理,在 lxml 中创建新元素或设置属性时,若涉及命名空间,也需使用 {namespace}tag 的 Clark 表示法。这是处理任何基于 XML 的文档格式(如 EPUB、SVG)时的常见陷阱。

提取与替换文本:在结构与语义间权衡

理想情况下,应该保留 <em>、<span> 等内联标签,因为它们承载语义信息。团队尝试过把块级元素的内部内容序列化为紧凑标记串,让LLM翻译时保留标签,再解析回DOM——但这种方法复杂且容易出错。

MVP阶段选择了更简单的方案:只翻译每个段落的纯文本,然后清除所有内联子元素,把译文直接设置到块级元素上。代价是译文中丢失斜体、粗体等格式,但换来了稳定性和开发速度。这个权衡被明确记录在文档中。

替换时有个细节:如果新建元素替换旧元素,会丢失原有属性;正确做法是保留元素本身,删除所有子节点后设置 .text,这样标签、属性和尾部文本都能保留。

批量翻译与重建流程

逐段翻译太慢且丢失上下文,实际流水线是:按spine顺序提取全书所有段落,每50段一批发送给LLM,返回后按相同顺序映射回各XHTML文件的对应元素。全局维护一个跨文件的有序块元素列表,翻译完成后逐文件回填,确保阅读顺序不变。

性能方面,一本约10万词的300页小说,解析加重建(不含LLM翻译时间)在标准VPS上耗时约2-3秒。瓶颈在 read_epub 和 write_epub 的ZIP压缩解压,lxml的DOM操作仅需毫秒级。相比LLM翻译的分钟级耗时,这部分开销可以忽略。

重建时不可忽视的细节

图片和CSS是独立的EPUB条目,不需要修改,ebooklib在读写时会自动保留。但修改过的XHTML条目必须把序列化结果写回 item.content,且要小心 xml:lang 属性和DOCTYPE声明——ebooklib有时会不一致地添加或删除它们,团队最终通过后处理序列化字符串来修正。

目录方面,由于只改文本不改结构,通常无需动TOC;但语言元数据和标题需要更新,通过 book.set_title()、book.set_language() 和 add_metadata() 完成。

EPUB 元数据与导航文档:EPUB 的元数据存储在 OPF 文件的 <metadata> 元素中,包括标题(dc:title)、语言(dc:language)、作者(dc:creator)等。ebooklib 提供了 set_title()、set_language() 和 add_metadata() 等便捷方法,但需注意这些方法可能不会自动更新所有相关字段(如 dc:title 可能同时存在多个 <dc:title> 元素,分别用于主标题和副标题)。导航文档(EPUB 3 的 Nav 或 EPUB 2 的 NCX)定义了目录结构,通常由 toc 元素表示。由于翻译仅修改文本内容而不改变结构,目录中的链接和层级通常无需调整,但若书籍标题或章节标题被翻译,则可能需要同步更新导航文档中的文本节点。ebooklib 的 book.toc 属性可用于访问和修改目录,但操作时需谨慎,避免破坏链接关系。

现实世界中的五类硬骨头

  1. 命名空间:始终使用命名空间映射。
  2. 自闭合标签:lxml可能把 <p></p> 变成 <p/>,部分阅读器会因此出错。团队最终发现许多EPUB并非严格合法的XML,于是改用更宽容的HTML解析器来处理。
  3. 编码:多数EPUB是UTF-8,但偶尔遇到Windows-1252等编码,ebooklib处理不佳,需引入 chardet 检测。
  4. 固定版式EPUB:每页是图片的固定布局书籍无法用文本方案处理,需要检测并回退到OCR或类PDF流程,目前仍是开放问题。
  5. 损坏的EPUB:野外大量EPUB格式不合法,团队实现了回退策略——先尝试严格XML解析,失败则用 lxml.html 解析器。

经验总结与遗留问题

几条核心经验:用ebooklib管理容器、lxml操作内容,不要重造ZIP/OPF层;用50本以上不同来源的书做回归测试;尽可能保留原始DOM,哪怕删除空span都可能破坏布局;尽早规划内联标签的处理,剥离标签只是临时方案。

团队目前正在探索保留标签的翻译方案——把序列化HTML发给LLM并要求返回翻译后的HTML,但可靠性时好时坏。如何在翻译中保留粗体、斜体、链接等内联格式的语义,仍是向社区开放的问题。

#EPUB解析#Python EPUB处理#ebooklib#lxml XHTML#AI图书翻译#EPUB重建#LLM翻译
分享:

相关推荐