☰
不用插件的Html转PDF:浏览器原生打印实现中文报表导出
2026/10/6 19:49:45 网站建设 项目流程

简介:这是一份基于jsPDF与html2canvas的网页转PDF解决方案,面向需要在前端实现高质量PDF导出的开发者,无需安装任何插件即可解决中文乱码、图片丢失、表格错位等常见问题。压缩包共10个文件,容量1.76MB,包含5个JavaScript库文件、2个HTML示例页面、1个字体文件及转换工具、1个样式表和预览图。其中ttf字体文件已针对中文显示优化,配套的fontconverter工具可帮助开发者转换自定义字体,确保输出内容与原网页所见一致。转换逻辑仅需6行核心代码,即可将任意网页对象矢量输出为PDF,完整覆盖文字、图片与表格场景。资源目前已有527人学习,适合前端开发、报表导出、文档生成等场景的开发者下载参考,内含可直接运行的示例与工具脚本,便于快速集成到项目中。

1. 不用插件的 html 转 PDF:为什么浏览器原生打印才是最合理的落点

“html 转 PDF 文件下载”这个需求,在后台管理系统、运营报表、合同打印这类场景里几乎天天遇到。很多人第一反应是引 jsPDF、html2pdf.js 这类前端库,却忽略了标题里“无需插件”四个字早已指向一条更稳的路:直接用浏览器的原生打印能力。做法是给页面套一套@media print样式,用window.print()唤起打印对话框,再把目标打印机选成“另存为 PDF”,输出就是标准 PDF 文件。中文、图片、表格本身就是打印引擎的强项,不需要任何额外依赖。这篇文章要解决的就是:这个方案为什么值得投入、完整源码怎么写、中文图片表格各自的坑在哪、上线前怎么验收,适合正在做内部系统导出功能的前端开发者参考。

2. 先搞懂浏览器打印机制:为什么原生方案比 jsPDF 更适合中文报表

2.1 为什么不用 jsPDF / html2canvas:插件方案与原生方案的本质差异

先说结论:jsPDF、html2pdf.js 这类库不是不能做 html 转 PDF,而是它们的技术路径决定了在“中文、图片、表格”这三个要求上天然吃亏。它们的核心原理是把网页用 html2canvas 截成一张大位图,再塞进 PDF 里。截图型 PDF 有三个绕不开的问题:第一,文字变成图片,PDF 里无法选中、搜索、复制,对合同和报表类文件是硬伤;第二,中文渲染依赖 canvas 里的字体嵌入,稍微配置不对就是乱码或方框;第三,长表格在截图时会整体被截断,跨页处理几乎是手动拼图,工作量直线上涨。

对比之下,浏览器原生打印走的是完全不同的路径。Chromium 调用系统打印管线时,是把 DOM 和 CSS 交给打印引擎重新排版,文字仍是文字,表格是真正的表格,图片按位图嵌入,中文字体走系统字体渲染。用户只要在打印对话框里选择“另存为 PDF”,就完成了一次 html 转 PDF。这个能力是浏览器自带的,不存在“插件”概念,也不需要在页面里引任何第三方包。

为了把选型理由说得更直白,我一般用下面这张表跟同事对齐:

方案依赖文字可复制跨页表格中文表现适用边界
原生打印另存为 PDF零依赖可复制自动分页,表头可重复系统字体,稳定用户手动点击,浏览器端
html2pdf.js引包不可复制跨页困难依赖字体嵌入简单页面,不追求文本可搜索
jsPDF + html2canvas引包,代码量大不可复制手动排版同左高度自定义的 PDF 布局
Puppeteer / Playwright服务端 Node 依赖可复制支持需配字体批量自动生成,非前端“无需插件”范畴

所以“最合理”三个字的判断依据很简单:零依赖、输出保真、支持中图表格齐全。原生打印这套组合拳,是满足标题全部约束的最小实现路径。它的局限我也直说:只能在真实浏览器里跑,无法在 Node 服务端静默生成;打印对话框需要用户点一次确认,不能完全绕过人工操作。如果是海量 PDF 自动生成,那就该上无头浏览器方案,属于另一个技术边界。

2.2 打印三件套的职责边界:@media print、@page、window.print()

浏览器打印机制的核心由三部分组成,很多人写打印功能只写了个window.print(),然后抱怨样式乱,其实是因为没把三者的分工搞清楚。

@media print管的是“介质样式”。当浏览器把当前页面送去打印或打印预览时,它会额外应用这段 CSS,专门负责隐藏导航栏、按钮、遮罩这些屏幕上需要但纸上多余的元素,也负责重置背景色、字号、间距。它跟屏幕样式是两套独立规则,打印时两者叠加生效:屏幕样式仍然在,打印规则只是做覆盖。

@page管的是“纸张物理尺寸”。它定义的是打印页面的宽度、高度、方向和页边距。比如@page { size: A4 portrait; margin: 12mm 15mm; }就明确告诉浏览器用 A4 竖版纸,左右 15mm、上下 12mm。这里用 mm 或 pt,不要用 px,因为纸张是物理单位,px 打印时会按 DPI 换算,容易出现偏差。

window.print()则是触发动作。它是同步阻塞调用:一旦执行,浏览器弹出打印对话框,后续 JS 暂停,直到用户点击“打印”“另存为 PDF”或取消关闭对话框。利用这个特性,可以在调用前准备好状态、调用后清理状态,但拿不到用户到底点了“打印”还是“取消”——这种“黑匣子”行为要在设计时接受,不要试图从返回值上做文章。

2.3 A4 页面在 CSS 里的真实尺寸:像素、毫米与缩放玄学

在动手写样式前,先把单位关系算清楚。A4 纸物理尺寸是 210mm × 297mm,按屏幕 96dpi 换算约等于 794px × 1123px。但打印时不要用这个像素值去定容器宽度,因为打印引擎按物理单位排版,px 会被折算,且 Chrome 会自动缩放。

实际操作中我一般这样设置:内容总宽度按纸张可用宽度算。比如 A4 竖版,页边距左右各 15mm 时,可用宽度就是 210 - 30 = 180mm,约等于 681px(96dpi 下),约合 510pt。设计表格或页面容器时,把宽度控制在这个可用纸宽内,就能避免 Chrome 在打印时强行缩小版面。

这里有个最常见的“玄学”:屏幕上看好好的页面,打印出来字变小了、列挤在一起。原因是网页宽度超过了纸张可用宽度,Chrome 为了把整个内容塞进一页,自动按比例缩放。想绕开它,要么把内容宽度固化为上面算出的可用纸宽,要么在@page里把 margin 调大,要么在打印对话框里把缩放比例手动改成 100%。纸上排版不是所见即所得,这是打印实现里最需要提前跟需求方对齐的一点。

3. 完整源码:页面结构、打印样式与自动触发的全套写法

3.1 页面结构:把屏幕功能区和打印区分开

打印模板的第一原则是“屏幕内容”和“打印内容”分离。我一般会用一个no-print类标记屏幕上要显示但不该进 PDF 的元素,再用一个print-root容器包住真正要输出的内容。这样只需在打印样式里对no-print做隐藏,业务代码完全不用动。

<!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>采购订单报表</title> <link rel="stylesheet" href="print.css"> </head> <body> <!-- 屏幕操作区:按钮、筛选条件、统计卡片等,都不进 PDF --> <div class="no-print" style="padding: 16px;"> <button id="printBtn">/* 打印样式:仅在本页被打印或打印预览时生效 */ /* 1. 纸张:A4 竖版,上下 12mm、左右 15mm 边距 */ @page { size: A4 portrait; margin: 12mm 15mm; } @media print { /* 2. 隐藏屏幕操作区与遮罩,避免混入 PDF */ .no-print { display: none !important; } /* 3. 页面底色强制白色,文字强制黑色,避免打印偏色 */ body { background: #fff !important; color: #000 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } /* 4. 打印容器宽度由纸张决定,不再受屏幕布局影响 */ .print-root { width: 100%; max-width: none; margin: 0; padding: 0; } /* 5. 表格:表头跨页重复 + 行不被切开 */ thead { display: table-header-group; } tr, img, .report-header, .report-footer { break-inside: avoid; } /* 6. 长文本自动换行,防止表格被不可见内容撑破 */ th, td { word-break: break-word; overflow-wrap: break-word; } /* 7. 去链接下划线,部分浏览器打印时会给链接自动加线 */ a { text-decoration: none; } }

这里要着重讲两个参数:print-color-adjust: exact和display: table-header-group。前者是保留背景色和文字颜色的关键,Chrome 系的-webkit-前缀和标准写法要同时写上,否则表格的表头深色背景在打印时会被 Chrome 自动去掉。后者告诉浏览器把thead当成“表头组”,当表格跨页时,每页顶部都自动重复输出这一行表头,不需要你手工拆分表格。

break-inside: avoid是个容易被忽视但极其重要的声明。它的作用是让一个元素尽量不被分页切断。放在tr上能避免某一行的数据被切成上下两半,放在img上能避免图片跨页截断。但注意它只是“建议性”的,当元素本身高度超过一页可用高度时,浏览器只能切断它,所以表格行内容不宜过高。

3.3 打印逻辑:等待图片加载完成、触发打印、收尾清理

JS 部分要解决的痛点很明确:图片没加载完就唤起打印对话框,打出来全是破图;打印完该恢复的标题没恢复;用户手快点了多次按钮。下面这份封装可以直接用,参数也写在注释里。

/** * 打印指定容器内容并支持另存为 PDF * @param {string} selector - 要打印的容器选择器,例如 '#printRoot' * @param {object} options * @param {number} options.timeout - 等待图片/字体的上限时间,默认 8000ms * @param {string} options.fileName - 生成的 PDF 默认文件名(不含扩展名) */ async function printHTML(selector, { timeout = 8000, fileName = '' } = {}) { const root = document.querySelector(selector); if (!root) { console.warn('[print] 未找到打印容器:', selector); return; } const mask = document.getElementById('printMask'); if (mask) mask.style.display = 'flex'; // 记录旧标题,打印结束后恢复,避免页面标签页被改名 const oldTitle = document.title; if (fileName) { document.title = fileName; } try { // 收集容器内所有图片,等待加载完成 const images = Array.from(root.querySelectorAll('img')); const imgTasks = images.map(img => { if (img.complete) return Promise.resolve(); // decode() 比 onload 更能确认图片已可绘制 return img.decode().catch(() => {}); }); // Promise.race 的作用是设置超时兜底: // 某张外链图片永久 pending 时,不能把打印流程卡死 await Promise.race([ Promise.all([ document.fonts.ready, ...imgTasks, ]), new Promise(resolve => setTimeout(resolve, timeout)), ]); // 关键:这个地方是同步阻塞的,脚本会停在 print() 这一行 window.print(); } catch (err) { console.warn('[print] 打印过程中发生异常,仍尝试唤起打印:', err); window.print(); } finally { // 恢复标题与遮罩,不论用户点了“打印”还是“取消”都会执行 document.title = oldTitle; if (mask) mask.style.display = 'none'; // afterprint 是部分浏览器在打印对话框完全关闭后触发的事件 window.addEventListener('afterprint', () => { document.title = oldTitle; }); } } // 绑定打印按钮 document.getElementById('printBtn')?.addEventListener('click', () => { printHTML('#printRoot', { fileName: '采购订单报表_20250108', timeout: 10000, }); });

三个关键点需要展开说。第一,img.decode()是等待图片真正解码完成的方法,比传统onload更可靠,它在图片已经加载完成但尚未绘制时也会返回 Promise,能避免“图片明明显示在屏幕上了,打印却缺一块”的时序问题。第二,document.fonts.ready是等待页面字体加载完毕的 Promise,把字体加载和图片解码放进同一个Promise.all,能保证打印瞬间所有资源就绪,这一步直接决定了中文有没有可能显示成占位方框。第三,fileName通过临时修改document.title来实现,因为 Chrome 在“另存为 PDF”时默认把网页标题当成文件名,打印结束再恢复原标题,用户不会发现网页标签页名字被动过。

4. 支持中文、图片、表格:三个高频输出问题各自逐一处理

4.1 中文不乱码:字体回退链与文本换行

中文在打印里“乱码”的真相往往不是编码问题,而是字体问题。HTML 文件声明了utf-8、页面在屏幕上也正常,但打印时如果系统里没有匹配的中文字体,打印引擎会静默使用缺省字体。最典型的现象是:标题里的楷体或黑体变成宋体,某些冷门字变成了方框。解决思路是给打印样式显式声明一条字体回退链,不是只写一个字体。

我常用的字体栈是这样的:font-family: "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", sans-serif;。顺序的含义是:优先使用思源黑体这类板正的现代中文字体,没有就回退到微软雅黑,再不行就用苹方,最后让系统兜底。这里不建议把 Web Font 放进打印字体链,因为打印引擎在字体未加载完成时会直接跳过,你还得在 JS 里等document.fonts.ready,反而增加不稳定因素;直接用系统字体是打印场景里最稳妥的做法。

另一个容易翻车的是全角标点和长文本换行。表格里的订单备注如果是一长串连续字母或数字,在td里可能被强制撑开,导致整列宽度异常。此时需要给th, td加上word-break: break-word和overflow-wrap: break-word,让浏览器在长单词处可以折断换行。同时给页面文档根元素声明lang="zh-CN",这对浏览器的断行规则和标点压缩算法有帮助,属于零成本的中文优化。

4.2 图片能打印出来:加载时序、跨域与截断

图片在打印时最容易出现的不是模糊,而是“空白”或“半张图”。原因基本可以归结为两类:图片仍在异步加载,打印引擎已经拿到了 DOM 快照;或者图片懒加载的loading="lazy"属性导致打印时图片还没触发加载。处理手段在上一章的printHTML封装里已经覆盖了大部分,但还有一个偏门坑值得单独说。

当页面里某张图片在屏幕上是display: none状态、打印时才显示时,部分 Chromium 版本会出现打印空白。原因是隐藏状态下的图片没有被渲染引擎真正绘制,打印流程读取不到它的像素数据。解决方法是:打印前把img的display属性强制置为block并触发一次重绘,或者干脆给图片包一层容器,让容器隐藏而不是图片隐藏。强制回流的标准写法是void img.offsetHeight;,这一行能让渲染引擎重新计算布局,属于打印场景里保平安的细节。

跨域图片(例如来自 CDN 或 OSS 的图片)在原生打印里其实没有问题,因为最终输出者是打印引擎本身,不涉及 canvas 跨域污染。但我个人还是建议:如果这些图片后续有可能被拿去生成 canvas 截图型 PDF,就在图片加载时给它加crossorigin="anonymous"属性,否则类似的场景一旦出现,就得逐个排查跨域头,那时候基本没有后悔药。图片尺寸上统一加上max-width: 100%和height: auto,再配合break-inside: avoid防止图片被分页线切成两半。

4.3 表格跨页不散架:重复表头、防切断与列宽固定

表格是 html 转 PDF 里最考验基本功的部分。一个小表格没问题,一旦超过一页,问题就全出来了:第二页开始没有表头、一行数据被切成上下两半、列宽在跨页后发生偏移。第一个问题对应 CSS 属性thead { display: table-header-group; },这个在第 3 章已经出现过,它的职责就是把表头变成“每组页面都自带”的页头。

第二个问题对应tr { break-inside: avoid; },它告诉浏览器每一行的数据尽量保持完整,不要在行中间断页。但如果行内有超长文本或超高图片,行高度超过一页可用空间时,这个属性会失效,所以还是要配合 4.1 的文本换行策略一起用。

第三个问题最隐蔽,也最影响观感。当表格总宽度超过纸张可用宽度时,浏览器不会报错,也不会横向截断,而是把整张表等比缩窄,看起来像“整体缩小了一圈”。缩窄之后,列宽比例也乱了。我的处理习惯是给表格加table-layout: fixed,然后给各列显式设置百分比宽度。table-layout: fixed会让浏览器严格按预设的列宽排版,而不是根据内容自动分配,这样跨页后列宽能保持稳定。比如四列表格可以设成25% 35% 15% 25%,合计正好 100%。同时把整个表格外层包一个min-width: 0的容器,避免表格被某个nowrap的单元格内容撑破。打印场景里,表格的“好看”远远不如“稳定对齐”重要。

5. 避坑:打印到 PDF 最容易翻车的 5 个现场

5.1 打印出来背景色全没了,表格表头变成白底黑字

现象:屏幕预览里表格表头是深蓝色底加白字,打印成 PDF 后背景色消失,只有文字,观感直接降级。原因:Chromium 默认为了省墨,会忽略网页里的背景色和背景图,除非页面显式声明需要保留。解决:在@media print里给*或具体元素加上print-color-adjust: exact。注意 Chrome/Edge 要写-webkit-print-color-adjust: exact,Firefox 认标准写法,两条都写上最稳妥。这个属性对background-color、background-image、渐变都生效,但只对“颜色满铺”的场景有意义;例如浅色隔行变色这种装饰性背景,不建议在打印里保留,容易让纸质版显得脏。

5.2 图片在屏幕上完整,打印出来却是空白或半张图

现象:页面里的商品图在屏幕上渲染得很完整,点击打印后 PDF 里对应位置是空白,或者只出现图片上方一条。原因:图片用了懒加载(loading="lazy"),打印的时刻浏览器还没来得及加载它;或者图片所在容器在屏幕上被隐藏,打印样式里重新显示,但渲染引擎并未重新绘制图片内容。解决:打印前把所有img的loading属性强制改为eager,调用img.decode()等待解码;若图片之前处于隐藏状态,先改成display: block,再执行void img.offsetHeight触发强制回流,最后才调window.print()。顺序不能反,回流必须在打印调用之前完成,否则打印引擎拿到的是旧布局。

5.3 表格跨页后第二页没有表头,行数据被齐腰截断

现象:一个 20 行的订单表格,第一页印了 12 行,第二页从第 13 行开始,页顶直接是数据,没有表头;部分行的上下单元格还被劈开到两页上。原因:CSS 里没做分页保护。浏览器默认允许在行内断页,也默认不重复表头。解决:thead { display: table-header-group; }让表头跨页重复;tr { break-inside: avoid; }让行保持完整。如果表格外层套了overflow: auto的滚动容器,打印时要给这个容器设置overflow: visible !important,否则它会把表格的可用高度限制成屏幕高度,那表格永远不会按纸张分页,而是被压缩到一页里。

5.4 PDF 文件的默认名是“未命名文档”或一串乱码 ID

现象:用户点击打印并选择“另存为 PDF”,弹出窗口里默认文件名是untitled.pdf或页面 URL 里的一段随机 ID,需要用户手动改名。原因:Chrome 在“另存为 PDF”时会取网页标题作为默认文件名。很多后台系统为了 SEO 或状态管理,标题栏是动态的,比如 “订单详情-38472947”,或者根本没设置title。解决:在调用window.print()前临时把document.title改成期望的文件名,打印结束在finally和afterprint里恢复。文件名里不要带/、\、:、*等特殊字符,Windows 和 macOS 对文件名都有各自的禁限规则,建议只保留中文、字母、数字、下划线和横线。

5.5 Chromium 和 Firefox 打印结果不一致,尺寸和边距差一圈

现象:同一个模板在 Chrome 里打印正常,在 Firefox 里偏大或者页边距明显不对称。原因:两个浏览器对@page的支持程度有差异。Chrome 支持@page { size: A4; margin: ...; },而 Firefox 对size的支持不完整,但会尊重 margin。另外两个浏览器内置的默认页边距值不同,如果你没有显式声明 margin,打印结果会各自采用默认值。解决:把打印功能锁定在 Chromium 内核浏览器(Chrome、Edge)上运行,这是绝大多数公司的既定标准;在代码里可以做一层检测,如果是 Firefox,弹提示建议使用 Chrome/Edge 以获得最佳打印效果。不要试图在代码里兼容所有浏览器的打印差异,成本远高于收益。

6. 验证与进阶:把打印模板变成可交付产物

6.1 交付前验收:用分页快照法检查每一页的完整性

打印功能开发完,最怕的是“我这边看着没问题”和“用户那边打出来有问题”的扯皮。我的习惯是定一套固定的验收流程:先在 Chrome 里打开页面,按Ctrl/Cmd + P进入打印预览,把每一个分页完整截图存下来。重点检查四样东西:第一,表头是否在每一页都重复出现;第二,表格行有没有被切断,切线是否刚好落在行间而不是行内;第三,图片有没有跨页,跨页图片是否被break-inside: avoid推到下一页并保持完整;第四,页面底部和页边距有没有出现内容被吞掉的情况。截图存档后再让需求方确认,能省掉绝大多数返工。

6.2 进阶:打印模板版本化,服务端批量导出留好边界

如果项目里有多处导出功能,建议把@media print样式抽成独立文件,把它当“页面资产的另一半”来管理,跟随业务代码一起发版。这样以后改表格列宽或字体,只要在打印样式里同步更新,不必每个页面各调一遍。这个方案天然定位在“用户在浏览器里手动点击打印”的场景;如果业务要扩展成“系统每天自动生成 500 份 PDF 报表发给客户”,那就应该把同一份打印 CSS 交给 Playwright 这类无头浏览器,在服务端用page.pdf()输出文件,那是另一条实现路径,不在“无需插件”这个标题范围内。我现在的习惯是接到类似需求先问一句:这是用户主动点按钮,还是要系统批量跑?前者直接用原生打印,后者才考虑上无头浏览器。把这条边界想清楚,后面能少走很多弯路;任何号称支持中文、图片、表格的第三方库,最终都绕不开字体、分页、缩放这三座大山。原生打印把这三件事都交给了浏览器,我们只需要把打印样式写对、把资源加载时序看好,剩下的交给打印引擎即可。这套方法论我已经在多个报表项目上反复验证过,希望帮到你。

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

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

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

立即咨询