最近接连有好几个朋友问我要 Python 里 HTML 转 PDF 的工具代码,有要生成报表的,有要做合同文档的,还有想给自己的网页做个 PDF 存档的。我发现大家的需求其实高度一致:不要复杂框架,不要重研发,就是想把一段 HTML 快速变成一份排版正常的 PDF。索性我把这几年实际项目里打磨出来的一套方案完整整理出来,从工具选型到代码封装,再到中文乱码、表格分页这些高频坑,全部讲清楚。
这篇内容适合刚入门 Python 的开发者,也适合被报表导出、电子发票、自动周报这类需求困扰的职场人。我的思路很直接:用 HTML 当模板,用 Python 调用渲染引擎把它转成 PDF,绕开 ReportLab 那种极端原始的排版方式,用接近写网页的体验快速产出专业文档。整个方案基于 pdfkit 底层方案,配合一份结构清晰的封装代码,基本能做到拿过去就能用。
1. 为什么我最终选了"HTML模板 + pdfkit"这条路
先说结论:如果你的目标是把一段带样式的 HTML 变成 PDF,趁早放弃用 Python 直接逐行绘制 PDF 的想法,那不是人干的活。
我最早接触这个需求时,想的是用 ReportLab 画一个数据报表。结果光是调一个带边框的表格,就要计算列宽、设置 TextStyle、处理单元格合并,代码写了三四百行,出来的效果还停留在"上世纪传真机"级别。后来我认清了一个事实:PDF 是呈现层,HTML 也是呈现层,与其在 PDF 坐标系里做排版,不如让 HTML 的盒模型替我做。我只需要把注意力放在模板设计上,渲染引擎负责把 HTML/CSS 翻译成 PDF 的排版指令。
在 Python 生态里,HTML 转 PDF 主要有三条路:
| 方案 | 渲染内核 | 上手成本 | 样式还原度 | 部署复杂度 | 我的评价 |
|---|---|---|---|---|---|
| pdfkit + wkhtmltopdf | Qt WebKit | 低 | 高(CSS2 完整,CSS3 大部分可用) | 中,需要额外安装二进制 | 通用场景首选,踩坑资料最多 |
| WeasyPrint | 自研渲染引擎 | 中 | 对打印 CSS 支持极其规范 | 高,依赖 Cairo/Pango 系统库 | 追求 W3C 规范建议选它 |
| ReportLab | 自带画布 | 高 | 低,需手写所有布局逻辑 | 低 | 只适合生成简单报表、标签 |
| fpdf2 | 自带画布 | 中 | 低 | 低 | 适合纯文本、简单图形 |
选择 pdfkit 最直接的原因是它背后的 wkhtmltopdf 是一个完整的浏览器内核。这意味着我平时写的 CSS 它基本都能认,表格、浮动、定位、伪类这些全都支持。WeasyPrint 虽然规范执行得更好,但我在内网服务器上装它的依赖时就吃过不少苦头,而且它对 CSS 的某些实现还挺"教科书",日常写惯的布局到了它上面反而会莫名崩。ReportLab 就不用说了,适合做从零绘制那种,做 HTML 转换纯属自虐。
还有一点很重要:wkhtmltopdf 是一棵成熟的树,虽然维护不活跃,但该解决的大坑都被社区踩得差不多了。网上搜"wkhtmltopdf 中文乱码""wkhtmltopdf 表格分页"能搜出大量真实解决方案,这就是隐性成本低的体现。对做项目而不是做研究的人来说,社区成熟度比技术先进性重要得多。
2. 环境准备:wkhtmltopdf 的安装才是真正的第一课
很多人拿到代码第一件事就是pip install pdfkit,然后运行报错No such file or directory,接着一脸问号。这里我必须强调:pdfkit 不是渲染引擎,它只是一个封装器,真正干活的是 wkhtmltopdf 这个独立程序。它的工作方式说白了就是把 HTML 作为参数传给你的系统里的 wkhtmltopdf 命令,再收集输出。所以环境配置的核心是装好 wkhtmltopdf。
2.1 各系统下的安装方式
Windows 用户最简单,去 wkhtmltopdf 官网下载对应版本的 exe 安装包,默认会装到C:\Program Files\wkhtmltopdf\bin下。装完记得把这个目录加进系统 PATH 环境变量。如果你不想动 PATH,也可以在代码里显式指定可执行文件路径,后面我会给封装代码。
Linux 服务器上分两类情况。Ubuntu/Debian 系的机器直接:
sudo apt-get install wkhtmltopdf但我更推荐去 GitHub Releases 里下载静态编译的 deb 包,因为 Ubuntu 软件源里的版本比较老,老版本在渲染某些 CSS 时容易出现奇怪问题。CentOS 这类系统则强烈建议下载静态编译版本,否则装系统自带的带 Qt 版本会给你拉进来一堆依赖,还有可能因为动态库冲突导致运行时直接崩溃。
macOS 用户一句话:
brew install --cask wkhtmltopdf装完后先验证一下环境:
wkhtmltopdf --version如果能正常输出版本号,说明核心程序就位了。如果这一步就报错,那大概率是 PATH 没配好,或者安装的是 32 位版本和系统不匹配。我在 Windows 11 上就遇到过一次,装的时候选了 32-bit,结果 64 位下的 Python 调用时怎么都找不到程序,重新装 64-bit 版才解决。
2.2 在代码里显式指定可执行文件
我强烈建议不要在代码里依赖 PATH,而是把 wkhtmltopdf 的路径放到配置里。这样项目换一台机器部署时,直接改一行配置即可,不用去动系统的环境变量。尤其在公司内网这种权限管控严格的环境里,你甚至可能没法改 PATH,只能靠代码指定。
import pdfkit WKHTMLTOPDF_PATH = "/usr/local/bin/wkhtmltopdf" # 换成你机器上的实际路径 config = pdfkit.configuration(wkhtmltopdf=WKHTMLTOPDF_PATH) pdfkit.from_string("<h1>Hello</h1>", "output.pdf", configuration=config)如果路径不对,你会得到一个很直白的异常提示。拿到这个异常别慌,第一件事就是检查路径是否存在,以及这个程序有没有执行权限。权限问题chmod +x一下就好。
3. 核心工具代码:一套能直接拿去用的封装
环境备齐之后,核心功能其实只有短短几行。pdfkit 提供了三种入口,我平时用得最多的是from_string和from_file:
import pdfkit # 从 URL 转 pdfkit.from_url("https://example.com", "webpage.pdf") # 从 HTML 文件转 pdfkit.from_file("report.html", "report.pdf") # 从 HTML 字符串转 html_content = "<html><body><h1>你好,世界</h1></body></html>" pdfkit.from_string(html_content, "hello.pdf")但直接这样调用,样式不可控,中文容易翻车,也没有页边距概念。真实项目里我需要的是一个"把 HTML 内容转成 PDF 文件"的通用函数,同时把编码、页面尺寸、页边距等参数全部收拢在一起。下面这套封装是我实际项目里用得最顺手的版本,模板渲染逻辑和数据业务解耦:
# -*- coding: utf-8 -*- """ 通用 HTML 转 PDF 工具函数 依赖:pip install pdfkit 前置条件:安装 wkhtmltopdf 二进制程序 """ import os import tempfile import pdfkit def html_to_pdf( html_content: str, output_pdf: str, wkhtmltopdf_path: str = "", options: dict = None, ) -> str: """ 将 HTML 字符串转换为 PDF 文件。 :param html_content: HTML 源字符串,必须是完整的 HTML 文档 :param output_pdf: 输出的 PDF 文件路径 :param wkhtmltopdf_path: wkhtmltopdf 可执行文件的绝对路径 :param options: 额外的 wkhtmltopdf 选项,会覆盖默认值 :return: 输出的 PDF 路径 """ # 指定 wkhtmltopdf 可执行文件 config = None if wkhtmltopdf_path: config = pdfkit.configuration(wkhtmltopdf=wkhtmltopdf_path) # 默认选项 default_options = { "encoding": "UTF-8", # 处理中文字符 "page-size": "A4", # A4 纸张 "margin-top": "15mm", "margin-bottom": "15mm", "margin-left": "10mm", "margin-right": "10mm", "no-outline": None, # 不生成 PDF 书签大纲 "enable-local-file-access": "", # 允许访问本地图片和 CSS 文件 "quiet": "", # 不输出冗余日志 } if options: default_options.update(options) # 写入临时 HTML 文件再转换 # 使用 from_file 而非 from_string 可以避免长字符串编码问题 tmp_file = None try: with tempfile.NamedTemporaryFile( mode="w", suffix=".html", encoding="utf-8", delete=False ) as f: f.write(html_content) tmp_file = f.name pdfkit.from_file( tmp_file, output_pdf, options=default_options, configuration=config, ) finally: if tmp_file and os.path.exists(tmp_file): os.unlink(tmp_file) return output_pdf这段代码最重要的是encoding: UTF-8这个选项,没有它,HTML 里只要有中文输出就基本是乱码。其次是enable-local-file-access,它的作用是让渲染引擎允许加载 HTML 里的本地资源文件,比如<img src="./logo.png">或者<link rel="stylesheet" href="style.css">。wkhtmltopdf 出于安全考虑默认禁止访问本地文件,如果不加这个选项,你的图片和样式会全部"消失",页面只剩文字和空占位。
3.1 为什么我用临时文件而不是 from_string
我见过不少人在from_string上栽跟头。它本身确实能用,但当你处理的 HTML 内容非常长,或者包含大量中文、特殊字符时,内部传递参数时可能出现编码截断。而且from_string只能处理纯 HTML 字符串,如果你的 HTML 里用了相对路径的资源文件,它不知道该去哪找文件,最终 PDF 里的图片全是裂开的。
我的做法是先写到临时文件,再调用from_file。这样把字符串内容持久化到磁盘,渲染引擎加载的根本是一份真实存在的 HTML 文件,天然就能正确解析相对路径。临时文件用完立刻删除,不会在项目目录里留垃圾文件。
这里还有一个细节:NamedTemporaryFile在 Windows 上如果打开着再被 wkhtmltopdf 读取,可能会报权限错误。我的解决方式是通过参数delete=False让 Python 先不自动删除文件,等转换完再手动os.unlink。这一步在 Windows 上是必须的,否则程序会间歇性崩溃。
4. 中文乱码、CSS 渲染失效和表格分页:三个高频问题的排查链路
代码写出来之后,真正的战斗才开始。我把这些年被问得最多的三类问题完整捋一遍,这些都是浏览器里正常、转到 PDF 就出幺蛾子的经典场景。
4.1 中文乱码:先查编码,再查字体
现象很简单:HTML 在浏览器打开一切正常,转出来 PDF 里所有中文全变成方框或者问号。
排查链路我建议从三步走:
第一步,确认 HTML 头部声明了<meta charset="utf-8">。pdfkit 虽然有encoding: UTF-8选项,但如果 HTML 本身的 meta 缺席,一些浏览器内核仍会默认按别的编码去解析。
第二步,确认传给 Python 的 HTML 字符串不是被截断或错误编码的。最常见的坑是你在 Windows 下用open().read()读文件时没指定编码,导致 Python 以 GBK 读入了 UTF-8 的中文文件。读取文件统一用open("template.html", encoding="utf-8")。
第三步,也是最隐蔽的一步:操作系统里没有中文字体。wkhtmltopdf 渲染文字时依赖的是系统字体库,不是浏览器自带的 web 字体能力。你在本地 Windows 上转了一版没问题,部署到无桌面环境的 Linux 服务器上一转,中文全成方块——十有八九是服务器上压根没装中文字体。
Linux 服务器上快速验证:
fc-list :lang=zh如果没有输出,说明系统里没有任何中文字体。装一个开源中文字体即可:
sudo apt-get install fonts-noto-cjk装完再试一次,注意渲染引擎可能在启动时就缓存了字体列表,所以装完字体后要把运行 Python 的进程重启。我在 Docker 环境里就栽过一次,进程没重启,字体装了等于没装。
CSS 方面,模板的字体栈要写得宽容一点,不要只写某个平台专属字体:
body { font-family: "Noto Sans CJK SC", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif; }这样在不同环境下都能优先命中系统可用字体。
4.2 CSS2 基本全能跑,CSS3 部分失效要降级
wkhtmltopdf 的内核是 Qt WebKit,一个老牌浏览器引擎,至今已经停止功能更新。这意味着你可以放心使用大部分 CSS2 特性,但 CSS3 里的 flex 布局、grid 布局这些现代特性,很可能渲染得稀烂甚至完全失效。
我遇到最典型的是 flex 布局的页头:
<div style="display: flex; justify-content: space-between;"> <span>公司名称</span> <span>机密文件</span> </div>浏览器里左边公司名、右边机密字样,标准两端对齐。到 PDF 里,这两个 span 直接竖着摞在了一起。排查到最后发现,这个版本的 WebKit 对justify-content: space-between的支持时有时无。
解决办法是把布局降级成全兼容方案:
.header { width: 100%; } .header .left { float: left; } .header .right { float: right; }或者干脆用 table 布局。给 PDF 用的模板我基本遵循一个原则:能用 table 解决的不用 float,能用 float 解决的不用 flex,能不用 grid 就不用 grid。虽然听着有点古板,但这样转出来的 PDF 在机器之间表现最稳定。
另外position: fixed也要慎用。wkhtmltopdf 对 fixed 定位的支持有历史性 bug,页脚页头建议用官方提供的header-html和footer-html机制,而不是在正文里用 fixed 元素做悬浮。
4.3 表格跨页丢表头、行被拦腰截断
这是处理报表时最头痛的问题。一个长表格跨了两三页,第二页开始就没有表头了,财务同事拿着这样的 PDF 根本没法看。
解决方案其实藏在 HTML 语义里:表头用<thead>包裹,wkhtmltopdf 在分页时会自动把 thead 内容重复显示在每一页顶部。
<table> <thead> <tr> <th>序号</th> <th>姓名</th> <th>部门</th> <th>绩效</th> </tr> </thead> <tbody> <!-- 这里放几十行数据 --> </tbody> </table>只要用了<thead>,表头重复基本不用额外 CSS。
真正麻烦的是行被从中间截断,上一页底部有半行,下一页顶部又有半行。解决这个问题的 CSS 很简单:
tr { page-break-inside: avoid; }加上这行之后,单行记录不会再被硬生生劈开。如果一行内容本身超高,这行还是会被拦腰截断,这时候需要检查是不是单元格里塞了过高的图片或者超长文本,从数据层面控制行高。
还有一个容易被忽略的细节:跨页表格的边框线。有时第一页底部行边框消失,第二页顶部行边框无故变粗。这是 WebKit 渲染跨页表格时的老 bug,没有什么完美的通用修复方案,我的规避技巧是给表格加底部留白,让最后一两行不要贴得太边,或者干脆把表格拆分到多个<table>分段渲染。
我给项目里做了一套通用的打印样式模板,每次写 HTML 模板时直接套用,能避免八成以上问题:
@media print { body { font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; font-size: 12px; line-height: 1.6; color: #333; } table { width: 100%; border-collapse: collapse; } thead { display: table-header-group; } tr { page-break-inside: avoid; } th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; } .page-break { page-break-before: always; } }5. 进阶玩法:批量生成与页眉页脚,把工具用成一个服务
基础转换跑通后,这套代码的真正价值在于可以集成进各种自动化任务。我给你分享几个我实际用过的扩展方向。
5.1 配合 Jinja2 做模板批量生成
处理几十份合同、上百份报表这种场景,最简单高效的方式是「Jinja2 渲染 + 批量转换」。我在项目里是把 HTML 模板单独存成文件,业务数据通过字典传进去,然后循环生成 PDF:
from jinja2 import Environment, FileSystemLoader import pdfkit env = Environment(loader=FileSystemLoader("templates")) template = env.get_template("report.html") # 模拟一批数据 data_list = [ {"name": "张伟", "department": "销售部", "score": 92}, {"name": "李娜", "department": "市场部", "score": 88}, {"name": "王强", "department": "技术部", "score": 97}, ] for i, item in enumerate(data_list, 1): html_content = template.render(item=item, index=i) output = f"output/report_{i}.pdf" html_to_pdf(html_content, output) print(f"已生成 {output}")模板里就是普通的 Jinja2 语法,比如{{ item.name }}、{% if item.score > 90 %}优秀{% endif %}。用模板引擎的好处是把循环、条件判断这些逻辑从 Python 里搬到模板里,代码更干净,维护模板时也不容易碰坏 Python 逻辑。
批量生成时注意一个问题:wkhtmltopdf 每次调用都是冷启动一个浏览器内核进程,单条文档生成的耗时在 0.5 到 2 秒不等。如果是上千份文档的规模,不建议用简单 for 循环逐个跑,要么用concurrent.futures.ProcessPoolExecutor做多进程,要么拆成消息队列给多个 worker 消费。我测试过,多进程在 4 核机器上大概能有三倍左右的加速,但也没必要追求极端,PDF 生成通常不是系统瓶颈。
5.2 压箱底的页眉页脚玩法
页脚页码、保密标记这类需求,标准做法是走 wkhtmltopdf 的header-html和footer-html选项。这两个选项可以各自指定一个 HTML 文件,渲染时会自动填充到每一页的顶部和底部。
我的页脚 HTML 长这样:
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <style> .footer-content { width: 100%; text-align: center; font-size: 9px; color: #888; font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; } .page-number { width: 100%; text-align: center; font-size: 9px; color: #666; padding-top: 2px; } body { margin: 0; padding: 0; } </style> </head> <body> <div class="footer-content">内部资料 · 请勿外传</div> <div class="page-number">第 <span class="page"></span> 页 / 共 <span class="topage"></span> 页</div> </body> </html>页脚里的<span class="page"></span>和<span class="topage"></span>是 wkhtmltopdf 在渲染时自动替换的特殊占位符,分别代表当前页码和总页数。注意页脚和页头是独立渲染的内容,字体栈需要单独定义;我在项目里就踩过这个坑,页脚里的中文一片方块,原因就是页脚 HTML 自己的 font-family 里没有中文字体名称。
调用时在 options 里加上:
options = { "footer-html": "templates/footer.html", "header-html": "templates/header.html", "footer-spacing": "5", "header-spacing": "5", } html_to_pdf(html_content, "output.pdf", options=options)header-spacing和footer-spacing是页眉页脚与正文之间的间距,单位是毫米。这个值如果设成 0,页眉页脚可能会和正文内容重叠。我通常设置 5 到 8 毫米,视觉上比较舒适。
5.3 大型文档的拆分合并策略
最后一个经验是处理超长文档的正确姿势。一次丢给 wkhtmltopdf 一个几百页的 HTML,内存占用会飙升,渲染时间也跟着指数级上升。我的策略是把大文档拆成多个相对独立的 HTML 片段,比如按章节拆,每个片段单独转 PDF,最后用 pypdf 合并:
from pypdf import PdfWriter writer = PdfWriter() pdf_files = ["chapter_1.pdf", "chapter_2.pdf", "chapter_3.pdf"] for pdf in pdf_files: with open(pdf, "rb") as f: writer.append(f) with open("full_document.pdf", "wb") as out: writer.write(out)注意:拆分方案会丢失跨章节的连续页码,所以页脚模板里的总页数topage只会显示当前 PDF 片段的页数。如果业务要求整个文档一个连续页码,你要么咬牙一次性转,要么在每个片段里通过--page-offset手动设置起始页码。这个参数的设置稍微有点绕,我平时不常用,真需要的时候会去翻 wkhtmltopdf 官方文档确认版本差异。
以上这些方法我每天都在用,写文件、转 PDF、清理临时文件、合并碎片,整套逻辑已经集成进了公司的自动报表推送服务里,每周稳定产出几百份 PDF 没有出过岔子。工具代码本身不难,难的是理解渲染引擎的脾气,希望这篇分享能帮你少走我当年走过的弯路。如果你在部署过程中遇到别的问题,建议先按"页面表现 → 排查 CSS → 检查字体 → 检查文件路径"这个顺序走一遍,大概率能自己定位到问题。