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) | 无头浏览器打印 | 渲染能力最强 | 安装体积大,打印分页控制弱 |
| wkhtmltopdf | WebKit 渲染 | 速度快 | 依赖系统 WebKit,CSS3 支持差 |
最终选了 WeasyPrint。原因很简单:EPUB 的排版要求正好落在 WeasyPrint 的能力范围内,它原生支持 CSS 分页媒体(@page规则),能控制页边距、页眉页脚、页码,这是做书籍排版的关键能力。
1.3 整体流程的四个阶段
我实现的转换流程分四步:
- 解包:EPUB 是 zip 容器,用
ebooklib读取元数据和文件清单。 - 筛选:按
spine顺序挑出正文 XHTML 文件,丢弃nav、toc等导航文件。 - 合并:把多个 XHTML 拼接成单一文档,同时保留各自的样式作用域。
- 渲染:用 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 没加载成功。排查步骤:
- 检查转换过程是否有 CSS 相关警告。
- 手动解压 EPUB,看
content.opf里的manifest是否有media-type="text/css"的条目。 - 我的代码里用
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去掉封面页。如果你需要,完全可以在这个基础上继续扩展。电子书格式转换这事,自己动手写一次,以后遇到什么奇怪格式都不会慌了。