简介:面对浏览器打印时排版错乱、中文乱码、图片丢失等常见痛点,这套完整源码提供基于jspdf与html2canvas的轻量化解决方案,无需任何浏览器插件即可将任意HTML页面高质量导出为PDF,尤其适合中文业务系统。压缩包共10个文件,大小仅1.76MB,核心包括JavaScript库、两个可直接运行的示例HTML页面、中文字体、字体转换工具及CSS样式,各模块用途清晰,便于二次开发。资源内置了转换好的中文字体,规避中文乱码问题,同时附带字体转换工具,允许开发者按需生成其他字体,仅需6行代码即可实现所见即所得的原样矢量输出,完整支持文本、图片与表格元素。已有528人学习下载,适合前端开发者、运维人员或需要批量生成报告、合同、票据等场景的工程师快速落地PDF导出功能。
1. 为什么说 html 转 pdf 文件下载,最合理的方案是浏览器打印
搜 html 转 pdf 文件下载,常见思路是装打印插件、引一个 JS 绘图库、或者写个后端接口生成文件。我这些年做过的模拟项目X里,凡是走这三条的,基本都会在两个字上翻车:乱码和排版。中文变成方块、表格跨页被拦腰切断、图片加载成空白。换到浏览器自带的打印能力之后,这些问题一次性消失了大半:chrome、edge、safari 早就内置了 PDF 输出,你点一下按钮,选“另存为 PDF”,一份完整的中文文档就落地了。
这个方向叫 web 页面 pdf 打印也好,叫 html 转 pdf 完整源码也好,核心都是同一件事:让浏览器排版引擎把页面当纸张渲染。它不需要任何插件,系统字体负责中文,网络图片自己加载,表格交给打印引擎分页,三样全支持。下面的内容从最小实现写起,逐步覆盖中文图片表格的处理、PDF 文件名控制以及常见的坑。适合那些不想引入重型依赖、又必须支持中文与图片表格的下载场景。
2. 最小可跑的实现:window.print() 加 @media print,一个函数搞定下载
2.1 为什么主线是浏览器打印,而不是 JS 绘图库、插件或后端服务
先回答一个经常被追问的问题:市面上明明有专门生成 PDF 的 JS 库,为什么还要绕回打印?原因很现实。那些库的本质是在 canvas 里把 DOM 重新画一遍,画出来的文字是矢量还是图片,取决于字体是否嵌入成功。中文字体动辄几 MB,很多库为了体积默认不嵌入完整字形,于是导出后中文要么变方块,要么变成图片格式又糊又大。
插件方案更尴尬。装了插件的人电脑上有按钮,换一台电脑没有就白搭,你还要专门写一堆兼容逻辑。至于后端方案,不是说不行,而是要起服务、管任务队列、处理文件存储,为了导一份报告搭这么大架子,成本没必要的。浏览器打印这条路,用的就是用户自己电脑上的渲染引擎,所见即所得,不需要任何额外运行时。
所以我一般给出的第一版实现,永远是这个最小骨架。它只有一段 HTML、一个按钮、两条 CSS 规则和一个 JS 函数,把这份代码存成一个 .html 文件双击打开,就是完整可用的 html 转 pdf 下载页面:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>模拟项目X-月度报告</title> <style> .no-print { display: block; } @media print { .no-print { display: none !important; } .report-body { width: 100%; } } </style> </head> <body> <!-- 打印按钮只在屏幕上显示 --> <button class="no-print" onclick="downloadPdf()">下载 PDF</button> <!-- 需要导出的内容 --> <div class="report-body"> <h1>月度数据报告</h1> <p>这是一段支持中文、图片和表格的正文内容。</p> </div> <script> function downloadPdf() { // 打印前临时修改 document.title,很多浏览器会用它作为 PDF 默认文件名 const oldTitle = document.title; document.title = '模拟项目X-月度报告.pdf'; // 触发系统打印对话框,用户选择“另存为 PDF”即完成下载 window.print(); // 打印对话框关闭后恢复原标题,避免影响页面显示 document.title = oldTitle; } </script> </body> </html>这段代码的逻辑说明:按钮的 onclick 绑定 downloadPdf(),函数里只做三件事——保存旧标题、改 document.title、调用 window.print()。print() 是阻塞调用,会停下来打开打印对话框,用户选好“另存为 PDF”并点击保存后,文件由浏览器直接写出。页面拿不到保存路径,也不需要后端参与写文件。
参数说明:@media print 里 .no-print 负责隐藏屏幕上才需要的按钮,避免按钮印到纸上;.report-body 不设固定高度,让内容按自然文档流分页。这个最小版本没处理懒加载图片和表格跨页,那属于第三章的内容。如果你要在这个基础上扩展,只要保证一个原则:屏幕样式和打印样式用同一套 DOM,打印样式只做覆盖,不要复制内容。
2.2 打印事件的两个时机:beforeprint 和 afterprint
真实业务不会像最小示例那样简单,页面上有图表、有滚动加载的列表、有临时需要塞进 PDF 里的信息。浏览器提供了两个非常实用的生命周期事件:beforeprint 和 afterprint,桌面端 Chrome、Edge、Firefox、Safari 都支持。beforeprint 在打印对话框打开前触发,是最后一次“整理现场”的机会;afterprint 在对话框关闭后触发,用来清理现场。
我一般会在 beforeprint 里做三件事:把还没加载完的资源等一等、把要写进 PDF 的临时信息填进 DOM、把 document.title 改成最终文件名。在 afterprint 里则反向操作:恢复 title、删掉临时节点、复位滚动位置。有一个细节要注意:打印时会触发窗口 resize,不要在 resize 监听里做二次渲染,容易造成死循环。
// 页面初始化时统一挂载打印生命周期 let originalTitle = ''; window.addEventListener('beforeprint', () => { // 记录原标题,打印结束要恢复 originalTitle = document.title; // 设置 PDF 默认文件名,Windows 不友好的字符会被过滤 document.title = buildPdfFilename('模拟项目X-月度报告', '2025-06'); // 把打印版专属的页脚信息填进 DOM,屏幕上不显示 const footer = document.getElementById('print-footer'); if (footer) { footer.textContent = new Date().toLocaleDateString('zh-CN'); } }); window.addEventListener('afterprint', () => { // 还原标题,避免页签一直显示成文件名 document.title = originalTitle; // 清空临时页脚 const footer = document.getElementById('print-footer'); if (footer) { footer.textContent = ''; } }); // 过滤 Windows 文件系统非法字符 function buildPdfFilename(prefix, dateStr) { const safe = `${prefix}_${dateStr}`.replace(/[\\/:*?"<>|]/g, '_'); return `${safe}.pdf`; }这段代码的逻辑说明:把 title 的写入放在 beforeprint 里,比放在按钮点击函数里更可靠。因为用户可能用 Ctrl+P 快捷键直接调出打印,不经过你的按钮,beforeprint 照样能兜住。print-footer 是一个只存在于打印样式里的节点,屏幕上 display: none,打印时显示,用来放日期、页码这类“只在纸上出现”的信息。
参数说明:originalTitle 用模块级变量保存,避免在 afterprint 里取不到旧值。buildPdfFilename 里的正则过滤了 Windows 文件名非法字符,防止用户保存时系统弹错。dateStr 传什么格式由业务决定,示例里传的是年月,如果想精确到天,就传 YYYY-MM-DD。这里点的目标是:文件名是用户在保存对话框里最终确认的,我们只能控制默认值,不能强制指定。
2.3 @page 规则:A4 尺寸、页边距与打印背景色
很多导出 PDF 自带一行网址和日期,看起来像水印,其实是打印对话框里“页眉和页脚”开关导致的系统输出。CSS 里的 @page 规则可以控制纸张大小和内容边距,但没有能力直接关掉系统页眉。这里要分清两个层次:@page 管理的是“纸张和内容区”,页眉页脚是对话框里的独立开关,两者互相影响。
@page { /* 纸张尺寸:A4 纵向 */ size: A4 portrait; /* 四边距,单位 mm,贴近打印习惯 */ margin: 14mm 10mm 16mm 10mm; } @media print { body { /* 保留背景色和背景图,否则会被浏览器默认去掉 */ -webkit-print-color-adjust: exact; print-color-adjust: exact; } .no-print { display: none !important; } .report-body { width: 100%; font-size: 12pt; line-height: 1.6; } }这段 CSS 的逻辑说明:@page 里的 margin 决定内容区在纸上的位置,size 决定纸张类型。print-color-adjust: exact 是给浏览器的一个请求:把 background-color 和 background-image 也输出到 PDF。如果不写,Chrome 默认会把所有背景色当“装饰”去掉,导出的表格只剩下白色底和数据文字,条纹背景全消失。
参数说明:size 还可以写 A4 landscape 做横向签批单,或者 A5 做小册子;margin 不要小于 5mm,打印机的物理边距普遍大于这个值,设太小会被设备裁掉。font-size 在打印时建议调到 12pt 左右,屏幕端的 14px 换算到纸上会偏小。如果正文包含大量表格,考虑把上下边距放宽到 16mm 以上,给分页位置留出喘息空间。
3. 中文、图片、表格三项硬骨头:为什么别人导出的 PDF 会翻车
3.1 中文不乱码:字体栈、字形缺失和文件编码的三层检查
html 转 pdf 翻车,十次里有八次是中文问题,搜索引擎里搜“pdf 图片中文设置”的人特别多。先给结论:浏览器打印方案下,中文渲染走的是系统字体引擎,只要操作系统装了中文字体,就不会缺字形。真正导致乱码的,通常是下面三件事里的一件:页面用了远程 web font 但打印时字体还没加载完、JS 绘图方案没有把中文字体嵌入导出文件、HTML 文件本身编码声明写错了。
第一层是字体栈。打印样式里把 font-family 写全,优先使用系统中文字体,避免每个设备因为缺字体退回不同的默认渲染。第二层是预加载。如果页面确实用了 @font-face 的 icon 字体或品牌字体,在 beforeprint 里用 document.fonts.ready 等字体加载完成再调 print()。第三层是编码,HTML 文件开头必须写 ,并且文件实际保存编码要跟声明一致,否则所有中文在解析阶段就已经错了,再好的打印样式也救不回来。
@media print { body { /* 中文优先使用系统的三套中文字体,按操作系统命中 */ font-family: "PingFang SC", "微软雅黑", "Noto Sans CJK SC", sans-serif; /* 中文标点按中文排版习惯收缩,避免标点悬挂行首 */ line-break: strict; word-break: break-word; } }逻辑说明:PingFang SC 对应 Mac,微软雅黑对应 Windows,Noto Sans CJK SC 对应 Linux 和 Android 场景。浏览器会从左到右逐个检测,当前系统有哪个用哪个,都没有就回退到 sans-serif。这个顺序保证了不同设备导出 PDF 时的中文字形稳定一致。
参数说明:line-break: strict 是中文排版规则,句号、逗号、引号会按中文习惯处理,不会孤零零出现在行首。word-break: break-word 防止长英文或 URL 把表格列撑破。如果读者反馈“在 Windows 上导出是宋体,在 Mac 上是苹方”,那不是错误,是字体栈依次命中导致的正常差异,PDF 打开效果不会缺字。
3.2 图片不丢失:懒加载回填、完整地址与 background-image 的坑
图片在 PDF 里消失,多半是三种原因:图片地址是相对路径或懒加载未触发、background-image 被打印样式禁用、图片还没加载完打印就开始了。现在主流网站都用懒加载,img 标签的真实地址放在>// 打印前统一处理图片:回填懒加载地址并等待解码 async function prepareImages(root = document) { // 1. 回填所有懒加载图片的真实地址 root.querySelectorAll('img[data-src]').forEach((img) => { if (img.dataset.src && !img.src) { img.src = img.dataset.src; } }); // 2. 过滤出需要等待的网络图片 const images = Array.from(root.querySelectorAll('img')) .filter((img) => img.src && img.src.startsWith('http')); // 3. 并发等待解码,单张失败不阻断全局 await Promise.all( images.map((img) => img.decode().catch(() => console.warn('image decode failed:', img.src)) ) ); }
逻辑说明:这个函数解决“图片显示一半”和“图片位置空白”两个典型故障。第一步处理懒加载组件留下的>@media print { table { /* 合并边框,避免分页处出现双重边框 */ border-collapse: collapse; width: 100%; page-break-inside: auto; } thead { /* 表头在每一页顶部都重复打印 */ display: table-header-group; } tr { /* 禁止一行被拆到两页 */ break-inside: avoid; page-break-inside: avoid; } td, th { border: 1px solid #333; padding: 6px 8px; vertical-align: top; word-break: break-word; } }
逻辑说明:display: table-header-group 是这一节里最重要的属性,它告诉打印引擎把 thead 当作“跨页重复的表头组”,每页顶部自动重渲一次。tr 的 break-inside: avoid 让一行整体移动到下一页,而不是在页边处劈开。border-collapse 合并相邻边框,避免分页线处出现加粗的边界。
参数说明:page-break-inside: avoid 绝对不能加在 table 上。新手很容易顺手把避断规则加到整表,结果是打印引擎把整张表当做一个整体块,第一页留下一大段空白,表直接挪到第二页。另外,如果表格带 rowspan 或 colspan,部分浏览器分页会变得不稳定,这时最稳的兜底方案是后端或前端按每页 25~30 行手工切片,每个分页包裹一个独立的 table 和 thead,而不是完全依赖打印引擎推算。
4. 把“下载”做实:PDF 文件名、打印对话框参数与多页控制
4.1 文件名怎么控制:document.title 是唯一可靠的抓手
“文件下载”这个词在 html 转 pdf 场景里经常被误解。浏览器打印出来的 PDF,保存对话框里的默认文件名取自页面