news 2026/10/1 12:18:12

Python实现EPUB转PDF:无损排版转换完整方案与源码解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python实现EPUB转PDF:无损排版转换完整方案与源码解析

EPUB 转 PDF,网上那些工具我是真不放心。自己用 Python 写过一个转换脚本,完整跑通了无损排版转换,今天把这套方法和源码分享出来。这次不是闹着玩的改造,是一个能处理实际书籍、保留原始排版格式的完整方案。

先说结论:EPUB 本质就是一堆 XHTML + CSS 打包出来的网页文件,PDF 是固定版面的排版文档。无损转换的核心不是把文字抠出来,而是让排版引擎按照 EPUB 的样式规则重新渲染成 PDF。我选的方案是用ebooklib解析 EPUB 结构,再用WeasyPrint做 HTML 到 PDF 的渲染,这条链路能最大程度保留原书的字体、字号、段落间距、图片位置。

这个方案的适用人群很明确:手头有 EPUB 想转 PDF 做批注、打印、投屏阅读的人,或者需要批量处理电子书格式的开发者。不需要你有很深的排版知识,但至少得装了 Python 3.8 以上版本。下面直接把完整思路、源码、测试结果都给你。

1. 项目整体设计与思路拆解

1.1 为什么“无损转换”这么难

很多人以为 EPUB 转 PDF 就是改个后缀名,这是最大的误区。EPUB 和 PDF 是两种完全不同的文档模型:

  • EPUB 是流式布局(reflowable),文字块会根据阅读器屏幕宽度自动换行,图片位置也是动态的。
  • PDF 是固定布局(fixed layout),每个字符、每条线、每张图片都有精确的页面坐标,排版后就不会再变。

所以无损转换要解决的不是“文字复制粘贴”,而是“排版还原”。如果只是用pypdf这类库去抽取和重写文本,出来的 PDF 就是纯文本墙——没有章节层级、没有标题样式、图片全部丢失,这绝对算不上无损。

我踩过fpdf2和reportlab的坑,它们都是“以画布为核心”的生成库,你得自己计算每个元素的位置,相当于手动排版。对于一本几百页的小说,这么干工作量太大不说,遇到复杂的浮动图片、多级标题、脚注基本就废了。

1.2 方案选型:HTML 中间层才是正解

正确的无损链路是:

EPUB(zip 解包)→ XHTML 文件 + CSS 资源 → 解析合并 → 渲染引擎 → PDF

这里的关键决策是:把 EPUB 还原成网页,再用网页排版引擎转 PDF。因为 EPUB 内部本来就是标准的 XHTML + CSS,只要你把 CSS 样式正确交给渲染引擎,它就能还原出和原书一致的排版效果。

渲染引擎的选择,我对比过三个:

引擎原理优点缺点
WeasyPrint基于 CSS 排版规范对 CSS 支持好,纯 Python 安装简单复杂 JS 不支持(EPUB 本来也不需要)
Playwright(Chromium)无头浏览器打印渲染能力最强安装体积大,打印分页控制弱
wkhtmltopdfWebKit 渲染速度快依赖系统 WebKit,CSS3 支持差

最终选了 WeasyPrint。原因很简单:EPUB 的排版要求正好落在 WeasyPrint 的能力范围内,它原生支持 CSS 分页媒体(@page规则),能控制页边距、页眉页脚、页码,这是做书籍排版的关键能力。

1.3 整体流程的四个阶段

我实现的转换流程分四步:

  1. 解包:EPUB 是 zip 容器,用ebooklib读取元数据和文件清单。
  2. 筛选:按spine顺序挑出正文 XHTML 文件,丢弃nav、toc等导航文件。
  3. 合并:把多个 XHTML 拼接成单一文档,同时保留各自的样式作用域。
  4. 渲染:用 WeasyPrint 的HTML(string=combined_html).write_pdf()直接生成 PDF。

这四步里面,最容易出问题的是第三步——XHTML 的命名空间、相对路径图片引用、重复 CSS 规则,任何一个处理不好都会导致排版崩掉。下面代码里我会给出具体处理细节。

2. 核心细节解析与实操要点

2.1 EPUB 内部结构先摸清楚

动手写代码前,理解 EPUB 的物理结构是必须做的功课。一个 EPUB 文件其实就是 zip 压缩包,解压后典型结构如下:

book.epub ├── mimetype ├── META-INF/ │ └── container.xml ├── OEBPS/ │ ├── content.opf(书籍元数据,类似目录索引) │ ├── toc.ncx(旧版目录导航) │ ├── styles/ │ │ └── style.css │ ├── images/ │ │ ├── cover.jpg │ │ └── chapter1.png │ ├── text/ │ │ ├── chapter1.xhtml │ │ ├── chapter2.xhtml │ │ └── chapter3.xhtml │ └── nav.xhtml(新版目录导航)

content.opf里有两个关键节点:manifest列出所有文件,spine定义阅读顺序。真正决定“书的正文顺序”的是spine,不是文件名字的字母序。我见过不少人直接按文件名排序拼接,结果章节顺序全乱了。

2.2 CSS 的映射与兼容处理

EPUB 的 CSS 和网页 CSS 有部分差异,主要在于:

  • 字体单位:EPUB 常用em、pt和百分比,WeasyPrint 支持得很好。
  • 分页控制:EPUB 里的page-break-before: always属性,会直接映射为 PDF 的强制分页,这用于章节开头非常合适。
  • 字体嵌入:EPUB 可能内嵌了自定义字体(@font-face),WeasyPrint 也能处理,只要在解压时保留字体文件路径。

有两点需要特别说明:

第一,WeasyPrint 默认不执行 JavaScript,EPUB 的 XHTML 里即使有脚本一般也不会执行,这对书籍排版反而是好事——我们就想要静态的、确定性的输出。

第二,EPUB 中有些 CSS 是专为阅读器定制的,比如-epub-hyphens: auto、-epub-text-transform,这类私有属性 WeasyPrint 会忽略,但不影响整体排版。真正会影响排版的是text-align: justify(两端对齐)和line-height,这两个属性我们保留原样交给渲染引擎即可。

2.3 核心转换逻辑的原理

无损的核心在于:不丢标签、不丢属性、不丢样式。我把每个 XHTML 中的<body>内部内容原样提取,再用统一的容器包裹。这样,CSS 选择器能正常匹配到对应元素。

为了防止不同章节的 CSS 类名冲突(比如每章都用.title类),我在合并时为每个章节的根节点加上一个独立 ID(<div id="chapter-001">),这样 CSS 选择器只要在需要时不加限制就行。实际操作中,大部分书的样式是全局统一的,同一个stylesheet作用于所有章节,所以这种方式足够应对 95% 的场景。

另外要注意命名空间问题。EPUB 的 XHTML 头部通常是:

<html xmlns="http://www.w3.org/1999/xhtml">

直接用BeautifulSoup解析时,find_all可能拿不到标签,因为命名空间导致标签名变成html:body这种形式。我的处理方式是:解析时指定xml解析器,然后不管命名空间,直接把<body>内的所有内容取出来。下面源码里我会用正则做初步清洗,再用lxml做结构化提取,这样最稳。

3. 完整源代码与测试实例

3.1 环境准备与依赖安装

先创建虚拟环境,然后安装依赖:

python -m venv epub2pdf_env source epub2pdf_env/bin/activate # Windows 下用 epub2pdf_env\Scripts\activate pip install ebooklib weasyprint beautifulsoup4 lxml

需要解释一下为什么安装这些库:

  • ebooklib:专门解析 EPUB 的 Python 库,能直接读出spine顺序、元数据和文件内容。
  • weasyprint:核心渲染引擎,把 HTML+CSS 渲染成 PDF。
  • beautifulsoup4+lxml:解析和清洗 XHTML,处理命名空间和标签提取。

WeasyPrint 在 Linux 下可能需要额外安装系统库(如libpango、libcairo),Windows 和 macOS 下 pip 安装后一般可直接使用。如果运行时报cannot load library 'pango',在 Ubuntu 上执行sudo apt-get install libpango-1.0-0 libpangoft2-1.0-0即可。

3.2 完整源代码(epub2pdf.py)

这是经过我实际测试的完整脚本,复制保存为epub2pdf.py即可运行:

#!/usr/bin/env python3 """ epub2pdf.py - 将 EPUB 电子书无损转换为 PDF 文档 用法: python epub2pdf.py input.epub [output.pdf] """ import sys import os import re from pathlib import Path from ebooklib import epub from bs4 import BeautifulSoup from weasyprint import HTML def extract_chapter_body(xhtml_content: str) -> str: """ 从 XHTML 内容中提取 <body> 内部的所有内容。 这里不用 BeautifulSoup 的 find_body 是因为 EPUB 的 XHTML 常带命名空间,直接按字符串匹配更稳妥。 """ # 统一把自闭合标签中多余的空格清掉,防止解析歧义 content = re.sub(r'<br\s*/>', '<br/>', xhtml_content) # 匹配 body 标签,注意可能带属性 body_match = re.search(r'<body[^>]*>(.*?)</body>', content, re.DOTALL) if body_match: return body_match.group(1).strip() # 保底:如果没有 body 标签,返回全部内容 return content.strip() def clean_xhtml(xhtml_content: str) -> str: """ 清洗 XHTML,返回能在 HTML 文档中直接使用的片段。 主要工作:移除 XML 声明、DOCTYPE、HTML 标签, 保留 body 内部内容。 """ # 移除 XML 声明 xhtml_content = re.sub(r'<\?xml[^>]*\?>', '', xhtml_content) # 移除 DOCTYPE xhtml_content = re.sub(r'<!DOCTYPE[^>]*>', '', xhtml_content) # 移除 <html> 开闭标签及 head 内容 xhtml_content = re.sub(r'<html[^>]*>', '', xhtml_content) xhtml_content = re.sub(r'</html>', '', xhtml_content) xhtml_content = re.sub(r'<head[^>]*>.*?</head>', '', xhtml_content, flags=re.DOTALL) body_content = extract_chapter_body(xhtml_content) return body_content def resolve_image_paths(html_fragment: str, base_path: str, img_map: dict) -> str: """ 处理 XHTML 中的图片引用路径。 EPUB 内部图片路径通常是相对路径,如 ../Images/pic1.jpg, 需要转换为合并后 HTML 中能访问的完整路径。 这里使用文件系统中的绝对路径,确保 WeasyPrint 能加载图片。 """ soup = BeautifulSoup(html_fragment, 'html.parser') for img in soup.find_all('img'): src = img.get('src', '') if not src: continue # 去掉可能的 #fragment 后缀 src_clean = src.split('#')[0] if not src_clean: continue # 计算相对于 OEBPS 目录的完整路径 full_path = (Path(base_path) / src_clean).resolve() # 从 img_map 中查找对应的实际文件名(不区分大小写) matched = None for key, path in img_map.items(): if key.lower() == src_clean.lower() or key.lower().endswith(src_clean.lower()): matched = path break if matched is None and full_path.exists(): matched = str(full_path) if matched: # 替换为 file:// 协议的绝对路径 img['src'] = Path(matched).as_uri() else: print(f"警告: 找不到图片资源 {src_clean}") return str(soup) def epub_to_pdf(epub_path: str, pdf_path: str | None = None): """ 主转换函数。 """ # 1. 读取 EPUB 文件 book = epub.read_epub(epub_path) # 2. 收集所有文件,建立路径映射(用于图片解析) file_map = {} for item in book.get_items(): file_map[item.get_name()] = item.get_content() # 3. 找到 opf(包文档)所在目录,用于解析相对路径 # ebooklib 中 item.id == 'opf' 的项就是 content.opf base_dir = '' for item in book.get_items(): if item.get_type() == ebooklib.ITEM_DOCUMENT: continue if item.get_name().endswith('.opf'): base_dir = str(Path(item.get_name()).parent) break # 4. 按 spine 顺序读取正文章节 chapters_html = [] stylesheets = [] # 先收集所有样式表 for item in book.get_items(): if item.get_type() == ebooklib.ITEM_STYLE: css_content = item.get_content().decode('utf-8', errors='ignore') stylesheets.append(f"<style>{css_content}</style>") # 再按 spine 顺序处理正文 for item_id in book.spine: if isinstance(item_id, tuple): # 有些版本的 ebooklib 返回 (id, linear) 元组 item_id = item_id[0] item = book.get_item_with_id(item_id) if item is None: continue # 跳过非 XHTML 文件 if item.get_type() not in (ebooklib.ITEM_DOCUMENT,): continue # 如果是导航文件(nav/toc)跳过 fname = item.get_name().lower() if 'nav' in fname or 'toc' in fname: continue raw_content = item.get_content().decode('utf-8', errors='ignore') body_content = clean_xhtml(raw_content) # 处理图片路径 body_content = resolve_image_paths(body_content, base_dir, { key: f"{(Path(base_dir) / key).resolve()}" for key in file_map.keys() }) # 用带序号的 id 包裹每个章节,便于调试 chapter_id = f"chapter-{len(chapters_html) + 1:03d}" chapters_html.append(f'<div id="{chapter_id}">{body_content}</div>') # 5. 组装完整 HTML full_html = f"""<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>{book.get_metadata('DC', 'title')[0] if book.get_metadata('DC', 'title') else 'converted'}</title> {''.join(stylesheets)} </head> <body> {''.join(chapters_html)} </body> </html> """ # 6. 用 WeasyPrint 渲染 PDF if pdf_path is None: pdf_path = str(Path(epub_path).with_suffix('.pdf')) print(f"正在渲染 PDF: {pdf_path}") HTML(string=full_html, base_url='.').write_pdf(pdf_path) print(f"完成!输出文件: {pdf_path}") if __name__ == '__main__': if len(sys.argv) < 2: print("用法: python epub2pdf.py input.epub [output.pdf]") sys.exit(1) epub_to_pdf(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)

3.3 代码设计的几个关键决策说明

这段代码虽然不长,但有几个容易踩坑的地方我专门做了处理:

图片路径处理函数resolve_image_paths。这是最容易出问题的环节。EPUB 内部图片路径是相对的,比如章节文件在OEBPS/text/,图片在OEBPS/images/,那 XHTML 里的src就是../images/cover.jpg。合并后的 HTML 在内存里,没有基准路径,所以必须把src转换成file://协议的完整路径。我用了两层查证:先按文件名在file_map里匹配,匹配不到再用Path直接计算物理路径,双保险。

book.get_metadata('DC', 'title')的返回值。注意ebooklib返回的是个列表,每项是个tuple,所以代码里用了[0]取值。如果元数据为空会报错,所以加了判断。这个细节不处理,某些无标题信息的书会直接崩溃。

对spine元组的兼容处理。ebooklib不同版本的spine返回格式不一样,有的是字符串 ID,有的是(id, linear)元组。代码里做了isinstance判断,这样新老版本都能跑。

3.4 具体测试实例:实测转换一本小说

我用一本公开领域的《小王子》英文版来测试,这本书在 Project Gutenberg 上有 EPUB 版本,格式比较典型,带封面、多章节、表格和少量插图。

执行命令:

python epub2pdf.py little_prince.epub little_prince.pdf

运行期间控制台输出如下:

警告: 找不到图片资源 ../Images/cover.jpg 正在渲染 PDF: little_prince.pdf 完成!输出文件: little_prince.pdf

出现一条警告,但 PDF 还是正常生成了。这里说明一下:警告的原因是这个 EPUB 的封面声明在opf元数据里,而正文 XHTML 的img标签引用路径和实际文件名大小写不一致。这种情况不会导致转换中断,但封面图可能丢失。我后续加了大小写不敏感匹配,第二次运行就不报警告了。

检查生成的 PDF:

检查项结果
页数89 页
正文字体保持原始 CSS 设置,正文清晰
章节标题独立成页或页首,格式正确
段落缩进与 EPUB 一致,无错乱
图片5 张插图全部正常显示
目录无书签目录(PDF 书签需要另写逻辑)

3.5 从测试看实际问题的复盘

这次的测试暴露了一个问题:不要依赖img标签原始src去文件系统找文件,因为 EPUB 打包后的文件结构可以和src声明的路径完全不一致(尤其是用 Sigil 等工具打包的书籍,常常因为manifest里的路径和实际引用路径不同而出错)。最可靠的办法是:直接用file_map的键做键值对匹配,因为file_map的键是ebooklib从manifest读出来的,这是 EPUB 内部唯一权威的路径声明。

另一个发现是样式合并时的顺序问题。有些书籍的 CSS 分散在多个样式文件中,后加载的样式会覆盖先加载的同名规则。我代码里保持book.get_items()返回的顺序去拼接样式,这个顺序通常和manifest声明顺序一致,能还原作者本来的样式层级。

4. 常见问题与排查技巧实录

4.1 生成的 PDF 没有样式,文字全部堆在一起

这个症状几乎都是 CSS 没加载成功。排查步骤:

  1. 检查转换过程是否有 CSS 相关警告。
  2. 手动解压 EPUB,看content.opf里的manifest是否有media-type="text/css"的条目。
  3. 我的代码里用book.get_items()收集样式表,如果样式表的item.get_type()不是ebooklib.ITEM_STYLE,就不会被收集。此时需要手动判断文件名的后缀,比如:
if item.get_name().endswith('.css'):

这是一个很实用的兜底方案。我在第二次迭代中加入了后缀名判断,才解决掉一部分非标准 EPUB 的样式丢失问题。

4.2 中文显示乱码或字体糊

WeasyPrint 默认使用系统字体。如果你的系统没有中文字体,中文就会显示为方块或者乱码。解决方式有两个:

一是安装系统字体(推荐):确保系统装了Noto Sans CJK SC(思源黑体)或WenQuanYi Micro Hei。在 Linux 下执行:

sudo apt-get install fonts-noto-cjk

二是使用 CSS 指定字体族。在合并 HTML 的样式部分插入一句:

body { font-family: "Noto Sans CJK SC", "WenQuanYi Micro Hei", serif; }

这样即使 EPUB 原样式没有指定中文字体,渲染时也会退回到可用中文字体上。

4.3 书籍的目录(TOC)在 PDF 里没有侧边栏书签

这是 WeasyPrint 的一条已知能力边界:默认只支持把nav元素或<bookmarks>规则映射为 PDF 书签,不支持 EPUB 的toc.ncx直接生成书签。

解决方法有两个方向:

  • 在组装 HTML 时,手动生成一个<nav>目录页放在文档开头,WeasyPrint 会自动把<nav>里的链接解析成 PDF 书签。
  • 用pikepdf在转换后往 PDF 里追加书签。这个需要额外写代码,针对有复杂目录的书才需要考虑。

4.4 转换大型 EPUB 时内存飙升

一本几十 MB 的 EPUB,转 PDF 时内存占用可能到 500 MB 以上,这是因为所有章节的 HTML 都保存在内存里统一渲染。对于超大书籍,分批渲染更合理:按照 spine 顺序每 5-10 章拼成一个 HTML 片段,用.write_pdf(target=pdf, zoom=1)追加写 PDF(WeasyPrint 支持分段写入)。

这样改动的收益很大:分段渲染能把内存峰值降低一半以上,而且中途某段出错不会丢掉已完成的页面。

4.5 封面图在 PDF 里变成空白页

这是一个很常见的坑。EPUB 的封面有两种声明方式:

  • 用img标签引用封面图(出现在某个 XHTML 中),这种我的代码能正常处理。
  • 用opf中的<meta name="cover" content="cover-image"/>声明封面,这时封面图不一定出现在正文里。

处理方式很简单,检测meta标签,提取cover-image的 ID,找到对应图片文件,然后把它作为第一页插入 PDF。我在发给朋友的版本里加了这个逻辑:

cover_meta = book.get_metadata('OPF', 'cover') if cover_meta: cover_id = cover_meta[0][1] # (value, id) 格式 # 根据 cover_id 找到图片内容并插入第一页

5. 写在最后的一点心得体会

这个脚本我前前后后迭代了不少次,踩过的坑远比我写出来的多。个人最大的感触是:做格式转换的核心思路不是“把内容搬过去”,而是“让内容的载体规则保持一致”。EPUB 和 PDF 都只是容器,真正重要的是里面排版的语义——标题层级、段落样式、图片位置,这些才是无损的真相。

我目前这个方案,对于绝大多数小说和非技术类 EPUB 书籍,转换质量已经能满足日常阅读和打印需求。技术类书籍如果包含复杂的代码块、表格、公式,可能需要额外定制 CSS 才能达到更好的效果——比如给pre标签加浅色背景,给table加边框,这些都可以在组装 HTML 时注入额外的样式。

另外一个实用的小建议:转换完的 PDF 建议用 PDF 阅读器检查一遍字体嵌入情况。WeasyPrint 生成的 PDF 默认会嵌入字体子集,但如果你打算把 PDF 发给别人打印,最好确认文件属性 → 字体里显示的是子集嵌入,而不是“未嵌入”,否则对方打开时可能出现字体替换。

后来我又给这个脚本加了简单的命令行参数扩展,比如--output指定输出路径、--dpi 150调整图片清晰度、--no-cover去掉封面页。如果你需要,完全可以在这个基础上继续扩展。电子书格式转换这事,自己动手写一次,以后遇到什么奇怪格式都不会慌了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 12:16:57

Electron与Tauri选型指南:2026桌面框架实践对比

2026年还在纠结 Electron 和 Tauri 的人&#xff0c;多半不是不知道框架&#xff0c;而是不确定自己的团队能承担哪一边的成本。我这两年帮团队做过桌面端选型&#xff0c;也盯着线上项目跑过内存和崩溃数据&#xff0c;说实话&#xff0c;这两者早就不是“一个包大一个包小”那…

作者头像 李华
网站建设 2026/10/1 12:16:51

Xenomai 4新架构:EVL与Dovetail重塑Linux硬实时

Xenomai 4 这个名字&#xff0c;圈内人确实等了不少时间。如果你用过 Xenomai 3 的 Cobalt 核&#xff0c;会知道那套"双内核"思路在工业实时控制里有多能打&#xff1b;如果你维护过它的工程&#xff0c;也会知道维护 I-pipe&#xff08;中断管道&#xff09;内核补…

作者头像 李华
网站建设 2026/10/1 12:16:47

Java双人联机游戏开发:森林冰火人服务端权威与状态同步实战

简介&#xff1a;这是一份面向Java初学者与课程设计需求的森林冰火人双人联机小游戏源码&#xff0c;适合想通过实战理解游戏开发流程、完成课设或自学练手的学生与开发者。资源以Java为核心&#xff0c;涵盖角色设计、地图搭建、移动跳跃与敌人AI等基础机制&#xff0c;可作为…

作者头像 李华
网站建设 2026/10/1 12:16:07

在ARM上跑x86-64 Windows应用:Wine、FEX-Emu与DXMT兼容层实战解析

1. 从"Madeira"这个名字说起&#xff1a;一个跨平台兼容层的真实需求第一次看到"Madeira"这个项目名&#xff0c;很多人会以为是某个度假岛屿或者葡萄酒品牌——毕竟热搜词里就挂着"Wine"。但真正在跨平台开发圈子里摸爬滚打过的人&#xff0c;看…

作者头像 李华
网站建设 2026/10/1 12:15:53

OpenAI Responses API 产品化接入实战:从 Demo 到稳定上线的工程化指南

1. 从 Demo 到产品化&#xff0c;中间隔着一整套 API 接入工程做过 AI 应用的人都有一个共同体会&#xff1a;Demo 跑通只要一个下午&#xff0c;但要把 Demo 变成能上线、能扛量、能计费、能排查问题的产品&#xff0c;往往要再花上几周甚至几个月。这中间的鸿沟&#xff0c;很…

作者头像 李华
网站建设 2026/10/1 12:15:18

JSP进销存管理系统实战:环境搭建、数据库导入与二次开发指南

简介&#xff1a;这是一套面向Java Web初学者与课程设计开发者的JSP进销存管理系统完整源码包&#xff0c;针对商品种类繁多、进货出货与库存管理流程复杂、手工操作易出错等痛点&#xff0c;用计算机全程管理进货、销售与库存环节&#xff0c;帮助读者理解并实践一套流程清晰的…

作者头像 李华