HTML转PDF不用插件:浏览器原生打印与中文表格避坑指南

📅 发布时间:2026/10/6 10:11:09
HTML转PDF不用插件:浏览器原生打印与中文表格避坑指南
简介针对网页转 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 langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 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 classtoolbar no-print button idprintBtn typebutton下载 PDF/button span stylefont-size:12px;color:#888;打印对话框里选择“另存为 PDF”/span /div article idprintArea h1销售订单/h1 p classorder-meta span订单号XD20250318001/span span日期2025-03-18/span span客户某某科技有限公司/span /p img src./logo.png alt公司 Logo width120 stylemargin-bottom:16px; h3订单明细/h3 table thead tr th序号/th th品名/th th规格/th th数量/th th单价/th th金额/th /tr /thead tbody trtd1/tdtd工业网关/tdtdGW-200/tdtd2/tdtd3,200.00/tdtd6,400.00/td/tr trtd2/tdtd温度传感器/tdtdTS-01/tdtd10/tdtd180.00/tdtd1,800.00/td/tr trtd3/tdtd安装调试服务/tdtd按次/tdtd1/tdtd1,500.00/tdtd1,500.00/td/tr /tbody /table div classsign 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 charsetutf-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否则某张图 404Promise 永远不落定用户点了按钮没任何反应这个 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 charsetutf-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的兼容差异。从那以后我改打印样式必开两个内核核对一遍这个习惯救了我很多次也希望帮到你。本文还有配套的精品资源点击获取