最近接连有好几个朋友问我要 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 wkhtmltopdfQt 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(wkhtmltopdfWKHTMLTOPDF_PATH) pdfkit.from_string(h1Hello/h1, output.pdf, configurationconfig)如果路径不对你会得到一个很直白的异常提示。拿到这个异常别慌第一件事就是检查路径是否存在以及这个程序有没有执行权限。权限问题chmod x一下就好。3. 核心工具代码一套能直接拿去用的封装环境备齐之后核心功能其实只有短短几行。pdfkit 提供了三种入口我平时用得最多的是from_string和from_fileimport pdfkit # 从 URL 转 pdfkit.from_url(https://example.com, webpage.pdf) # 从 HTML 文件转 pdfkit.from_file(report.html, report.pdf) # 从 HTML 字符串转 html_content htmlbodyh1你好世界/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(wkhtmltopdfwkhtmltopdf_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( modew, suffix.html, encodingutf-8, deleteFalse ) as f: f.write(html_content) tmp_file f.name pdfkit.from_file( tmp_file, output_pdf, optionsdefault_options, configurationconfig, ) 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 relstylesheet hrefstyle.css。wkhtmltopdf 出于安全考虑默认禁止访问本地文件如果不加这个选项你的图片和样式会全部消失页面只剩文字和空占位。3.1 为什么我用临时文件而不是 from_string我见过不少人在from_string上栽跟头。它本身确实能用但当你处理的 HTML 内容非常长或者包含大量中文、特殊字符时内部传递参数时可能出现编码截断。而且from_string只能处理纯 HTML 字符串如果你的 HTML 里用了相对路径的资源文件它不知道该去哪找文件最终 PDF 里的图片全是裂开的。我的做法是先写到临时文件再调用from_file。这样把字符串内容持久化到磁盘渲染引擎加载的根本是一份真实存在的 HTML 文件天然就能正确解析相对路径。临时文件用完立刻删除不会在项目目录里留垃圾文件。这里还有一个细节NamedTemporaryFile在 Windows 上如果打开着再被 wkhtmltopdf 读取可能会报权限错误。我的解决方式是通过参数deleteFalse让 Python 先不自动删除文件等转换完再手动os.unlink。这一步在 Windows 上是必须的否则程序会间歇性崩溃。4. 中文乱码、CSS 渲染失效和表格分页三个高频问题的排查链路代码写出来之后真正的战斗才开始。我把这些年被问得最多的三类问题完整捋一遍这些都是浏览器里正常、转到 PDF 就出幺蛾子的经典场景。4.1 中文乱码先查编码再查字体现象很简单HTML 在浏览器打开一切正常转出来 PDF 里所有中文全变成方框或者问号。排查链路我建议从三步走第一步确认 HTML 头部声明了meta charsetutf-8。pdfkit 虽然有encoding: UTF-8选项但如果 HTML 本身的 meta 缺席一些浏览器内核仍会默认按别的编码去解析。第二步确认传给 Python 的 HTML 字符串不是被截断或错误编码的。最常见的坑是你在 Windows 下用open().read()读文件时没指定编码导致 Python 以 GBK 读入了 UTF-8 的中文文件。读取文件统一用open(template.html, encodingutf-8)。第三步也是最隐蔽的一步操作系统里没有中文字体。wkhtmltopdf 渲染文字时依赖的是系统字体库不是浏览器自带的 web 字体能力。你在本地 Windows 上转了一版没问题部署到无桌面环境的 Linux 服务器上一转中文全成方块——十有八九是服务器上压根没装中文字体。Linux 服务器上快速验证fc-list :langzh如果没有输出说明系统里没有任何中文字体。装一个开源中文字体即可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 styledisplay: 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 模板单独存成文件业务数据通过字典传进去然后循环生成 PDFfrom jinja2 import Environment, FileSystemLoader import pdfkit env Environment(loaderFileSystemLoader(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(itemitem, indexi) output foutput/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 langzh-cn head meta charsetutf-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 classfooter-content内部资料 · 请勿外传/div div classpage-number第 span classpage/span 页 / 共 span classtopage/span 页/div /body /html页脚里的span classpage/span和span classtopage/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, optionsoptions)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 → 检查字体 → 检查文件路径这个顺序走一遍大概率能自己定位到问题。
企业数字化 ERP 产品动态
相关推荐
Qwen3-VL ConvRot多模态模型INT8量化部署实战 1. 这不是“跑个模型”那么简单:Qwen3-VL-32B-Ultra-Heretic-MiniMax-H3-INT8-ConvRot的本质挑战你看到这个标题的第一反应可能是:“又一个大模型部署教程?”——但我要先泼一盆冷水:这根本不是常规意义上的“部署”。它是一场在硬… · 2026/9/26 21:00:02
CAD文件拖拽不进窗口?UAC权限隔离与兼容性设置修复全指南 1. 这个拦路虎到底是谁:UAC权限隔离下的拖拽禁运如果你常年跟AutoCAD打交道,大概率在某个版本某个系统上碰到过这个邪门问题:文件就在桌面上,鼠标左键按住,拖进CAD绘图区,结果光标变成一个带禁止符号的圆圈… · 2026/9/26 20:59:56
机器学习实现音乐推荐系统:从数据清洗到SVD模型调优 简介:这套基于机器学习的音乐推荐系统项目工程,面向毕业设计、课程设计、工程实训与大作业等开发场景,适合需要完整可运行项目用于复现或二次扩展的学生与开发者。资源共1106个文件,压缩包约73.94MB,以Java/JSP后端源码… · 2026/9/26 21:34:53
大模型搜索占位实战:用任务智能体AI重构SEO优化闭环 搜索这件事,确实变天了。以前我们讨论“搜索排名优化”,默认是百度、谷歌里网页链接的排名;现在再聊,绕不开“任务智能体AI”“大模型搜索”“AI搜索答案引用”这些新东西。用户搜索一个问题,得到的不再是一排蓝色链接… · 2026/9/26 21:34:53
Cursor + Spring Boot实战:用TaoToken统一Key从零写一个RESTful API /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 21:34:53
开源AI编程本地部署实战:从模型选型到工具链配置全指南 两年多前,我第一次用AI写代码的时候,怎么也想不到这玩意儿会卷得这么厉害。Cursor火起来之后,几乎每个技术群都在聊AI编程;GitHub Copilot、Windsurf、Trae这些商业产品一个比一个猛,好像不开个会员就没法正常写代码了… · 2026/9/26 21:34:33
模拟退火算法在路径规划中的应用:原理、Python实现与GUI展示 1. 从一次给客户排配送路线说起:路径规划问题到底难在哪几个月前,有个做同城配送的朋友找我帮忙,说手头有二十几个取送货点,每次靠人工排路线,司机跑出来的距离忽高忽低,客户催得紧的时候根本来不及细排。我… · 2026/9/26 21:34:26
SSM商品拍卖系统毕设全攻略:从需求分析到并发控制与答辩 1. 这个毕设题目为什么值得做:拍卖系统的定位与难点拆解先交代个背景。2026年的毕设季,很多同学会在选题阶段卡住很久。我的建议始终是那句老话:选一个"看起来简单、做起来有东西讲"的题目。商品拍卖系统恰好是这种矛盾体——功能边… · 2026/9/26 21:34:26
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46