☰
HTML转PDF不用插件:浏览器原生打印与中文表格避坑指南
2026/10/6 10:11:09 网站建设 项目流程

简介:针对网页转 PDF 的常见痛点,这份源码以 jsPDF 与 html2canvas 为核心,实现无需插件的纯前端转换,可完整保留网页中的中文、图片和表格,并借助内置信纸字体解决乱码问题。压缩包共 10 个文件,包括 5 个 JavaScript 脚本、2 个可直接运行的 HTML 示例、1 个中文字体文件(simfang)、1 个字体转换工具以及相关的 CSS 样式与预览图,整体约 1.76MB,文件职责划分清楚,便于按需替换与二次开发。代码不仅是导出接口的简单封装,还覆盖字体转换、文件读取与保存等周边环节,使用者可跳过自行处理字体和兼容性的过程,只需少量配置即可将任意网页对象矢量输出为 PDF,适合前后端分离项目中的报表导出、合同存档等场景。目前已有 527 人学习下载,对前端开发者、文档系统维护者和需要快速落地 PDF 生成功能的中级工程师都有直接参考价值。

1. 不用插件也能转 PDF:为什么浏览器原生打印是最合理的方法

做后台管理系统,订单、对账单、合同这些页面,几乎都会被要求加一个“下载 PDF”的按钮。早些年我第一反应是上 wkhtmltopdf 或者 Puppeteer,但服务端要装二进制、要维护中文字体库,麻烦不说,前端改一版样式,服务端那边经常对不上。后来踩了几次坑才想明白:浏览器自己就有把 HTML 变成 PDF 的能力,window.print() 把页面送进打印流程,用户选“另存为 PDF”就完成了下载。这个方案不装任何插件,中文由系统字体原样输出,图片、表格是浏览器原生排版,天生比 canvas 重绘或第三方解析器靠谱。这篇把我在生产环境一直在用的完整源码拆开讲,从打印样式到分页、图片、表格的坑一次说清。

2. 完整源码:一个订单页从 HTML 到 PDF 下载的最小实现

2.1 为什么这段源码能同时解决中文、图片、表格

先给结论:这个方案的核心不是某个库,而是浏览器内核的排版引擎。页面在屏幕上是什么样,打印成 PDF 就是什么样,因为 Chrome 和 Firefox 的打印功能实际上是按当前页面重新排版并输出成 PDF 文件。HTML 里的中文、图片、表格,本身就是浏览器的本职工作,所以不存在“转换器不支持某个标签”的玄学问题。很多人绕远路去装这个插件、那个二进制,反而引入了字体缺失、CSS 不兼容、表格错位等一堆新麻烦。

下面这份是我常用的订单导出模板,可以直接复制成独立 HTML 文件跑通。它包含三块:屏幕上显示的工具栏、真正要打印的内容区、触发打印的脚本。图片路径先用 ./logo.png,没有就换成任意本地图或者 data URI 临时测。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>销售订单详情导出</title> <style> * { box-sizing: border-box; } body { margin: 0; background: #f5f6f8; font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif; } .toolbar { position: sticky; top: 0; padding: 12px 16px; background: #fff; border-bottom: 1px solid #e5e7eb; z-index: 10; } #printArea { max-width: 820px; margin: 24px auto; padding: 40px 32px; background: #fff; border: 1px solid #e5e7eb; } h1 { font-size: 22px; text-align: center; margin: 0 0 8px; } h3 { font-size: 16px; margin: 20px 0 8px; } .order-meta { color: #555; font-size: 13px; text-align: center; margin-bottom: 20px; } .order-meta span { margin: 0 8px; } table { width: 100%; border-collapse: collapse; margin: 16px 0; } th, td { border: 1px solid #333; padding: 8px 10px; font-size: 14px; text-align: left; } th { background: #f0f0f0; } .sign { margin-top: 32px; display: flex; justify-content: space-between; font-size: 14px; } /* ---------- 打印样式 ---------- */ @media print { body { background: #fff; } .toolbar, .no-print { display: none !important; } #printArea { max-width: none; margin: 0; padding: 0; border: none; } @page { size: A4; margin: 15mm 12mm; } h1 { font-size: 20px; } tr { page-break-inside: avoid; } thead { display: table-header-group; } } </style> </head> <body> <div class="toolbar no-print"> <button id="printBtn" type="button">下载 PDF</button> <span style="font-size:12px;color:#888;">打印对话框里选择“另存为 PDF”</span> </div> <article id="printArea"> <h1>销售订单</h1> <p class="order-meta"> <span>订单号:XD20250318001</span> <span>日期:2025-03-18</span> <span>客户:某某科技有限公司</span> </p> <img src="./logo.png" alt="公司 Logo" width="120" style="margin-bottom:16px;"> <h3>订单明细</h3> <table> <thead> <tr> <th>序号</th> <th>品名</th> <th>规格</th> <th>数量</th> <th>单价</th> <th>金额</th> </tr> </thead> <tbody> <tr><td>1</td><td>工业网关</td><td>GW-200</td><td>2</td><td>3,200.00</td><td>6,400.00</td></tr> <tr><td>2</td><td>温度传感器</td><td>TS-01</td><td>10</td><td>180.00</td><td>1,800.00</td></tr> <tr><td>3</td><td>安装调试服务</td><td>按次</td><td>1</td><td>1,500.00</td><td>1,500.00</td></tr> </tbody> </table> <div class="sign"> <div>客户签字:____________</div> <div>销售签字:____________</div> </div> </article> <script> document.getElementById('printBtn').addEventListener('click', async () => { await ensureImagesLoaded(); window.print(); }); // 等所有图片加载完再进入打印流程,避免 PDF 里出现空白图 async function ensureImagesLoaded(root) { const imgs = Array.from(root.querySelectorAll('img')); await Promise.all(imgs.map(img => { if (img.complete) return Promise.resolve(); return new Promise(resolve => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); }); })); } </script> </body> </html>

这份源码的逻辑分三层。结构层用article#printArea圈住真正要输出的内容,工具栏放在它外面,并加了no-print类;样式层把@media print里的规则和屏幕样式分开,打印时去掉工具栏的干扰;行为层在点击按钮后先确保图片资源可用,再调window.print()。window.print()会阻塞后续 JavaScript 执行,直到用户关闭打印对话框,所以不用担心脚本状态和打印动作抢跑。

参数上有几个值得注意的细节。@page里的size: A4指定纸张,margin: 15mm 12mm指定上下和左右页边距,这个值会直接影响每页能装下多少行表格,我在后面章节会专门展开。tr { page-break-inside: avoid; }保证一行数据不会被切到两页中间,thead { display: table-header-group; }让长表格跨页后每页自动重复表头,这两条是表格打印不翻车的基本盘。ensureImagesLoaded里 load 和 error 都 resolve,是因为如果某张图挂了,Promise 也不能一直 pending,否则按钮点了毫无反应,用户会以为功能坏了。

2.2 触发逻辑与运行时行为

按钮事件用了async/await,主要图的是ensureImagesLoaded()执行期间的异步等待。这里有个常被忽略的点:img.complete只能说明图片的加载流程走完了,不代表图片一定加载成功,所以失败时也要放行。.once: true是给监听器加的一次性约束,避免同一个事件反复触发 resolve。整个ensureImagesLoaded是通用的,不管页面里是订单图、合同附件图还是用户的签名图,只要它们在 DOM 里,都会被覆盖到。

window.print()在桌面 Chrome、Firefox、Edge 都会弹打印预览窗口,用户把目标打印机切到“另存为 PDF”,点击保存就是文件下载,不需要任何虚拟打印机驱动。这一步是系统级的 PDF 输出能力,不是页面里某个“插件”提供的。打印预览里看到的页面,已经是@media print样式生效后的样子,也就是说你可以直接在预览里确认工具栏是否隐藏、表格是否跨页,所见即所得。

Safari 桌面版的打印行为也类似,但 iOS 上的表现会不一样:它基本忽略@page边距,且部分page-break-*属性不完全生效。如果客户主要用 iPad 看报表,这条要单独记一笔,后面避坑章节我会讲补救思路。

3. 打印样式三板斧:@media print、@page 与分页控制的参数设置

3.1 @media print:屏幕样式与打印样式如何隔离

@media print的本质是一层样式覆盖,它不会清空屏幕样式,只是在打印介质上追加或覆盖规则。写的时候有个原则:屏幕样式照常写,打印样式只做“减法”和“微调”。减法是指隐藏交互元素,比如按钮、工具栏、弹窗遮罩;微调是指调整边距、字体大小、颜色,让纸面阅读更舒服。

实际开发里最常见的翻车是把整个页面display: none,再重新拼一个打印版 DOM。这种做法会让所有图片重新走一遍加载流程,如果原图是懒加载的,打印版里就是一片空白。我一般只用no-print类做局部隐藏:

.no-print { display: none !important; }

!important在这里是必要的,因为有些第三方组件的内联样式优先级更高,不加它盖不住。有同事问过我为什么不用visibility: hidden,区别在于display: none会直接让元素不占位,visibility隐藏后还会留白,打印出来多一块空白区域,所以隐藏必须用display: none。

另一个容易忽略的是背景色。浏览器打印的默认行为是丢弃背景色和背景图,省墨水。如果表头灰底、状态标签的红色背景必须出现在 PDF 里,光写th { background: #f0f0f0; }不够,还要配合print-color-adjust,这个我在第四部分细说。

3.2 @page:纸张大小、页边距与默认页眉页脚的博弈

@page规则控制的是页面框模型,它不属于某个 DOM 元素,而是定义打印纸的物理属性。常用写法是:

@page { size: A4; margin: 15mm 12mm; }

size可以是 A4、A3、Letter,也可以写成size: 210mm 297mm自定义尺寸。margin的四值写法与普通 CSS 一致。这两个值定下之后,每页的内容区域宽度就是纸张宽度减去左右边距,例如 A4 横向边距 12mm 时,内容宽度约为 186mm。做表格列宽适配时,这个数就是设计依据。

@page还支持:first、:left、:right伪类,分别作用于首页、奇数页、偶数页。合同模板里首页通常要留出更大的上边距,可以单独写:

@page :first { margin-top: 30mm; }

还有一个容易误会的点:打印预览里出现的网址、日期、页码,不是页面 DOM 里的东西,而是浏览器打印对话框的“页眉页脚”功能,它画在@page的 margin 区域里,页面 CSS 无法完全关闭它。你能做的是把@page的 margin 调小,让浏览器没地方画,但这也会压缩正文空间,不划算。我的做法是在按钮旁边加一句提示文案:“打印对话框里取消‘页眉和页脚’勾选”,把操作交给用户,比在 CSS 里跟浏览器较劲省事得多。

3.3 分页控制:让表格不跨页断行的三个参数

分页控制的旧语法是page-break-inside、page-break-before、page-break-after,新规范里叫break-inside、break-before、break-after。兼容性上,page-break-*更稳,Chrome、Firefox、桌面 Safari 都认,移动端 Safari 的支持仍有欠缺。我生产环境里沿用page-break-*,不做渐进增强,原因是打印需求大多来自桌面端,保持行为一致更可控。

属性常用取值效果适用场景
page-break-insideavoid元素内部不跨页tr、img、.sign这类不能断开的块
page-break-beforealways强制分页每个合同章节另起一页
page-break-afteralways当前元素后分页封面结束后强制下一页
page-break-insideauto允许内部自然跨页长表格整体,避免拖动整块导致大片空白

tr { page-break-inside: avoid; }的含义是每一行尽量保持完整,行数多时表格整体跨页,而不是某一行被拦腰截断。thead { display: table-header-group; }是让表头在每页重复的旧兼容写法,有些浏览器对position: sticky的打印支持不稳定,我用display: table-header-group反而从来没出过问题。.sign签字区如果恰好落在页面底部被切开,也可以在打印样式里加page-break-inside: avoid让它整体挪到下一页,代价是上一页下边会空出一截,这是正常的。

4. 中文、图片、表格三大专项:乱码、白图与错位的逐一处理

4.1 中文:字符集声明与字体策略,为什么原生打印不乱码

中文字体是 HTML 转 PDF 的重灾区,症结通常不在转换器,而在字体解析顺序。浏览器打印 PDF 时,文字按系统字体渲染,Windows 有微软雅黑、宋体,macOS 有苹方,Linux 服务器上常见 Noto Sans CJK。只要页面声明了正确的字符集,字体回退栈里包含中文字体,输出就不会乱码。

html { -webkit-text-size-adjust: 100%; } body { font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", sans-serif; }

字符集声明必须放在<head>最前面,即<meta charset="utf-8">,位置太靠后,某些浏览器在解析到它之前已经按默认编码读了一部分内容,中文就变乱码了。这也是 html 转 pdf 场景里“中文变成方块字”最常见的原因,跟转换方法无关。

字体回退栈的顺序也有讲究。如果第一顺位是无中文字形的西文字体,比如font-family: Arial, sans-serif,中文会落到第二顺位 sans-serif,具体渲染成什么由操作系统决定,在不同机器上就可能不一致。把这个栈写成“中文字体在前、西文字体在后”,才保证 Windows 和 macOS 看到同一套字形。还有一个细节是font-family里的中文字体名带空格时不需要引号,浏览器能正确解析,不要画蛇添足。

4.2 图片:相对路径、base64 与跨域图片的打印差异

页面里的图片来源有三种,打印行为差别不小。同源相对路径和 base64 图片都随 HTML 一起被浏览器渲染,打印时直接复用内存里的位图,基本不会丢。跨域 URL 的图片只要屏幕显示正常,打印一般也能正常输出,因为浏览器打印不涉及 canvas 重绘,不需要重新拉取像素,也就没有 canvas 跨域污染那套限制。这一点是浏览器原生打印相对 html2canvas 的核心优势,后者处理跨域图片时经常画出来是灰块。

真正导致 PDF 里图片空白的,通常是时序问题:用户点下载按钮时<img>还在加载中,window.print()已经把当前画面送进了打印管线,图片位置只留下空白占位。解决手段就是第 2 章里的ensureImagesLoaded(),它本质上是一个“等所有图片完成再进打印”的闸门。

async function ensureImagesLoaded(root) { const imgs = Array.from(root.querySelectorAll('img')); await Promise.all(imgs.map(img => { if (img.complete) return Promise.resolve(); return new Promise(resolve => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); }); })); }

这里有两个边界要注意。一是img.complete在图片加载失败时也为true,所以不能用它判断图片“可用”,只能判断“流程结束”;二是错误也要 resolve,否则某张图 404,Promise 永远不落定,用户点了按钮没任何反应,这个 bug 在线上非常隐蔽,控制台又不报错,排查起来很费劲。另外我还习惯给打印区的图片设固定宽高,避免图片晚加载导致排版跳动、连带分页位置变化。

4.3 表格:宽表、长表、嵌套表的打印排版

表格是三样里最容易错位的。宽表的问题是列多,打印纸张窄,浏览器会压缩列宽,内容折行,甚至溢出被裁剪。长表的问题是跨页,行数据被切断。嵌套表的问题则更隐蔽:外层一个大行里套了张子表,子表建了一个很长的行,把外层行顶到下一页,上一页留下大片空白。

针对宽表的处理,我给表格区设置固定布局和断词策略:

table { width: 100%; table-layout: fixed; border-collapse: collapse; } th, td { word-break: break-all; overflow-wrap: break-word; }

table-layout: fixed让列宽由表头第一行决定,不随内容自动伸缩,宽度可控;word-break: break-all负责把超长单词或数字串拆行,否则td里的订单编号一大串,会把整列撑爆。注意不能在这里全局用white-space: nowrap,那是给短标签用的,一旦用于长文本,打印时文本直接溢出单元格边界,比折行难看得多。

长表跨页的规则上面提过,核心就两条:tr { page-break-inside: avoid; }和thead { display: table-header-group; }。如果表格非常长,可能还要给<tbody>里的行分组加page-break-inside: auto,让浏览器自行裁决从哪里断开,而不是强行把整段塞进一页导致严重留白。

嵌套表的处理要反过来思考:把page-break-inside: avoid加在内层子表上,而不是外层行上。外层行避免断开会让整个大行跨页移动,内层子表避免断开则只保证子表内部完整,外层分页时从子表和后续内容之间断开,留白面积会小很多。这个差别我最初没意识到,直到一次打印对账单,PDF 里有三分之一页是空的,才反应过来是外层避免断开的副作用。

5. 避坑:html 转 pdf 常见翻车点,现象、原因、解决一次说清

5.1 PDF 里中文变方块

现象:打印预览和输出的 PDF 里,中文全部变成“口口口”方块,数字和英文正常。

原因:页面缺少<meta charset="utf-8">,或者字体回退栈第一个字体不包含中文字形。浏览器遇到无法映射的中文字符时,会输出占位方块而不是报错。

解决:在<head>最靠前位置声明字符集,并把中文字体放在字体栈前面。排查时先开 DevTools 看 Computed 面板里font-family的实际生效值,如果显示的是 Arial,就按上面 4.1 的代码修正。

5.2 图片在 PDF 里是空白占位

现象:屏幕上图片正常,打印预览里图片区域空白,有时带一个碎图图标。

原因:点下载按钮时图片还没加载完,window.print()捕获的是缺图状态。还有一种情况是图片容器没设宽高,加载完成后把整页往下顶,导致后续表格被挤到下一页,出现奇怪的大片空白。

解决:点击事件里先await ensureImagesLoaded()再打印。这类问题在低网速环境下高发,内网部署的系统反而很少见到,容易漏测。

5.3 表格被拦腰截断,边框断一截

现象:表格某一行上下各半,分别出现在两页,边框在分页处断裂。

原因:page-break-inside: avoid没有加到tr上,或者干脆没写。有的开发者把它加在table上,结果整个表格被当成一个整体,行数一多就直接整体推到下一页,上一页空出大半页。

解决:把规则写成tr { page-break-inside: avoid; },并保留thead { display: table-header-group; }。嵌套表格场景下,注意规则应该加在内层子表和外层行之间合理分配,参考 4.3 的处理。

5.4 表头灰底、告警红字全部丢失

现象:屏幕上有背景色的元素,打印出来后变成白底黑字,视觉强调效果全没了。

原因:浏览器打印的默认行为不渲染背景色,这是省墨设计,不是 bug。

解决:在打印样式里加print-color-adjust: exact,兼容旧内核时写成-webkit-print-color-adjust: exact。也可以引导用户在打印对话框勾选“背景图形”,但每次让用户手动勾体验不好,CSS 层解决更稳妥。

5.5 打印预览多出网址、日期和页码

现象:PDF 页脚出现长串 URL 和当前日期,样式跟页面格格不入,删不掉。

原因:那是浏览器打印对话框自带的“页眉页脚”功能,画在页面 margin 区域内,不属于 DOM,页面 CSS 无法直接控制。

解决:软件层面只能提示用户取消勾选“页眉和页脚”;产品层面如果较真,可以把@page的 margin 设成0,再把内容区自己的 padding 加上去,这样浏览器那个区域小到几乎不可见,但会牺牲打印边距的灵活性。权衡之后我选择前者,提示文案放按钮旁边,用户点两下就关掉了。

6. 验证与进阶:让 PDF 输出稳定的最后一道工序

6.1 上线前用两个内核各过一遍

Chrome 和 Firefox 对@page边距、page-break-inside的实现有细微差异,最稳妥的办法是每改一次打印样式,两个内核各走一遍打印预览。我固定看四件事:中文是否有生僻字变成方块、表格跨页处是否完整、图片数量与屏幕一致、页边距是否均匀。移动端 Safari 单独记录,iOS 上window.print()行为和桌面端不同,部分分页属性直接忽略,客户里有 iPad 用户就要提前说清楚。

6.2 打印前切换 UI 状态:beforeprint 与 afterprint

有些页面打印前需要折叠折叠面板、展开隐藏字段或者给表格套汇总样式,直接用@media print写样式可以解决一部分,但遇到需要操作 DOM 的场景就要监听打印事件。beforeprint在打印预览触发前执行,afterprint在打印流程结束后执行,适合做临时的界面状态切换。

window.addEventListener('beforeprint', () => { document.body.classList.add('printing'); expandAllPrintSections(); }); window.addEventListener('afterprint', () => { document.body.classList.remove('printing'); });

注意beforeprint在浏览器的支持时间点有差异,Chrome 是异步触发,不能保证在window.print()同步阻塞前完成,所以依赖 DOM 操作的逻辑要写得快,复杂计算不放在这里。这个技巧的典型场景是“屏幕上默认折叠商品明细,打印时必须全部展开”,用@media print改display虽然也行,但展开后的分页位置会变化,事件里手动操作更容易控制。

6.3 什么时候该放弃浏览器打印方案

如果需求变成“点击后直接弹出文件下载,不出现打印对话框”,浏览器原生打印就不够用了,得走服务端渲染 PDF 或者前端 canvas 重绘。这类方案要把中文字体嵌入、图片跨域、表格切割三个老问题全部重新解决一遍,工作量陡增。在做技术选型时我会先问产品经理一句:用户是真的需要文件落盘,还是只要能看能存就行?多数后台报表场景,“另存为 PDF”完全满足诉求,没必要为了一键下载引入整套重渲染链路。

另外建议把打印样式单独拆成一个 CSS 文件,和业务样式分开维护。出问题时先在该文件里搜索@media print,不要翻几万行业务代码。有次上线前一晚客户反馈合同 PDF 里表格断了一截,我本地 Chrome 看是好的,后来用 Firefox 一测才定位到是page-break-inside的兼容差异。从那以后我改打印样式必开两个内核核对一遍,这个习惯救了我很多次,也希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询