
简介这是一份基于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约等于 681px96dpi 下约合 510pt。设计表格或页面容器时把宽度控制在这个可用纸宽内就能避免 Chrome 在打印时强行缩小版面。这里有个最常见的“玄学”屏幕上看好好的页面打印出来字变小了、列挤在一起。原因是网页宽度超过了纸张可用宽度Chrome 为了把整个内容塞进一页自动按比例缩放。想绕开它要么把内容宽度固化为上面算出的可用纸宽要么在page里把 margin 调大要么在打印对话框里把缩放比例手动改成 100%。纸上排版不是所见即所得这是打印实现里最需要提前跟需求方对齐的一点。3. 完整源码页面结构、打印样式与自动触发的全套写法3.1 页面结构把屏幕功能区和打印区分开打印模板的第一原则是“屏幕内容”和“打印内容”分离。我一般会用一个no-print类标记屏幕上要显示但不该进 PDF 的元素再用一个print-root容器包住真正要输出的内容。这样只需在打印样式里对no-print做隐藏业务代码完全不用动。!doctype html html langzh-CN head meta charsetutf-8 title采购订单报表/title link relstylesheet hrefprint.css /head body !-- 屏幕操作区按钮、筛选条件、统计卡片等都不进 PDF -- div classno-print stylepadding: 16px; button idprintBtn>/* 打印样式仅在本页被打印或打印预览时生效 */ /* 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让浏览器在长单词处可以折断换行。同时给页面文档根元素声明langzh-CN这对浏览器的断行规则和标点压缩算法有帮助属于零成本的中文优化。4.2 图片能打印出来加载时序、跨域与截断图片在打印时最容易出现的不是模糊而是“空白”或“半张图”。原因基本可以归结为两类图片仍在异步加载打印引擎已经拿到了 DOM 快照或者图片懒加载的loadinglazy属性导致打印时图片还没触发加载。处理手段在上一章的printHTML封装里已经覆盖了大部分但还有一个偏门坑值得单独说。当页面里某张图片在屏幕上是display: none状态、打印时才显示时部分 Chromium 版本会出现打印空白。原因是隐藏状态下的图片没有被渲染引擎真正绘制打印流程读取不到它的像素数据。解决方法是打印前把img的display属性强制置为block并触发一次重绘或者干脆给图片包一层容器让容器隐藏而不是图片隐藏。强制回流的标准写法是void img.offsetHeight;这一行能让渲染引擎重新计算布局属于打印场景里保平安的细节。跨域图片例如来自 CDN 或 OSS 的图片在原生打印里其实没有问题因为最终输出者是打印引擎本身不涉及 canvas 跨域污染。但我个人还是建议如果这些图片后续有可能被拿去生成 canvas 截图型 PDF就在图片加载时给它加crossoriginanonymous属性否则类似的场景一旦出现就得逐个排查跨域头那时候基本没有后悔药。图片尺寸上统一加上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: exactFirefox 认标准写法两条都写上最稳妥。这个属性对background-color、background-image、渐变都生效但只对“颜色满铺”的场景有意义例如浅色隔行变色这种装饰性背景不建议在打印里保留容易让纸质版显得脏。5.2 图片在屏幕上完整打印出来却是空白或半张图现象页面里的商品图在屏幕上渲染得很完整点击打印后 PDF 里对应位置是空白或者只出现图片上方一条。原因图片用了懒加载loadinglazy打印的时刻浏览器还没来得及加载它或者图片所在容器在屏幕上被隐藏打印样式里重新显示但渲染引擎并未重新绘制图片内容。解决打印前把所有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()输出文件那是另一条实现路径不在“无需插件”这个标题范围内。我现在的习惯是接到类似需求先问一句这是用户主动点按钮还是要系统批量跑前者直接用原生打印后者才考虑上无头浏览器。把这条边界想清楚后面能少走很多弯路任何号称支持中文、图片、表格的第三方库最终都绕不开字体、分页、缩放这三座大山。原生打印把这三件事都交给了浏览器我们只需要把打印样式写对、把资源加载时序看好剩下的交给打印引擎即可。这套方法论我已经在多个报表项目上反复验证过希望帮到你。本文还有配套的精品资源点击获取