资讯详情 jsPDF配合jspdf-autotable导出PDF中文乱码的完整解决方案
📅 2026/10/17 14:40:25
做导出功能遇到中文乱码我猜你已经搜了不少文章代码复制了一堆结果要么还是乱码要么直接白屏。这个问题在我这儿折腾了一整个下午最后发现根子不在代码而在PDF文件本身的字体机制。今天的主题就是把使用jsPDF配合jspdf-autotable导出表格时中文乱码这个坑彻底讲透并给出一套能直接跑通的完整方案。内容包含原理分析、方案选型、完整实操和错误排查适合正在做报表导出、订单导出、数据表格下载的前端开发者参考也适合被PDF乱码折磨过的新手。1. 乱象背后的根因PDF里根本没有中文字形1.1 一段最简单的Demo就能复现的问题先看一个典型的“错误示范”。很多人的代码是这么写的import { jsPDF } from jspdf; const doc new jsPDF(); doc.text(你好世界, 10, 10); doc.save(test.pdf);你以为这样能在PDF里打出“你好世界”实际上打开一看大概率是“,,,,”或者一串方块。问题来了同样一个字符串在浏览器里写进span能正常显示为什么到了jsPDF里就变成了乱码原因其实非常简单jsPDF默认使用的字体是内置的标准字体之一比如helvetica、courier、times。这些标准字体只覆盖拉丁字符集里面根本没有中文字符对应的字形。PDF里的文字不是像HTML那样“系统帮我找字体渲染”而是需要一个明确的字体资源来描述每个字符的轮廓。没有这个资源阅读器自然不知道“你”这个字该怎么画出来。1.2 PDF的字体机制为什么不像HTML一样自动渲染HTML渲染中文之所以不需要你做额外处理是因为操作系统和浏览器会自动从系统中挑选一款中文字体来完成渲染。但PDF是一个自包含文档格式它的设计原则是无论在哪个设备上打开都应该呈现出一模一样的效果。为了做到这一点PDF需要在文件内部嵌入字体的字形描述信息或者至少引用一个阅读器能识别的外部字体。jsPDF默认生成的内容只带基础字体引用而这些基础字体是不支持中文的。所以当你调用doc.text(你好)时jsPDF拿着这个字符串尝试在字体表里查找对应字形查不到要么输出一个不存在的编码要么干脆显示成空白或方框。用个通俗点的比喻你让一台没装中文字库的打印机去打印中文打印出来当然是乱码。jsPDF就是这个状态默认情况下它是个“没装中文字库的打印机”。理解了这一点所有解决方案就都指向同一个方向让jsPDF拥有一款支持中文的字体并且把这个字体嵌入到导出的PDF文件里。1.3 表格场景为什么更容易踩坑如果只是doc.text简单打几个字很多人可能还碰不到乱码问题。但一旦用了jspdf-autotable去渲染表格情况就更复杂了。因为jspdf-autotable内部有自己的绘制逻辑它不是简单地调用doc.text而是在Canvas上测量文本宽度、计算单元格高度然后统一绘制。这意味着就算你全局调用了doc.setFont(中文字体)表格组件内部仍然可能使用的是它自己默认的helvetica。所以你会遇到一种更让人抓狂的现象标题和普通文本用doc.text输出是中文正常的但表格区域里中文全部乱码。这不是代码偶然出错而是表格组件和PDF实例的字体设置没有打通。2. 方案选型先想清楚再动手别急着写代码2.1 三种主流方案对比内嵌字体、转图片、服务端渲染遇到中文乱码网上能搜到的方案其实可以归成三类。第一类是在前端解决给jsPDF注册中文字体文件然后正常调用doc.text和表格组件。优点是纯前端独立完成文字在PDF里是可选中、可搜索的文本层后续复制、检索、无障碍阅读都没问题。缺点是字体文件通常比较大而且需要多一步字体转换和注册操作。第二类是曲线救国用html2canvas把整个DOM区域截成图片再把图片塞进PDF。这个方案的好处是“所见即所得”页面长什么样导出就是什么样中文自然没问题。但它带来的新问题很现实PDF里的文字全是图片像素不可选中、不可搜索放大之后还发虚同时整页变成一张大图文件体积翻好几倍。另外一个隐藏问题是图片的清晰度高度依赖设备像素比在高分屏上做出来的图还行在普通屏上导出的PDF清晰度会被人追问。第三类是服务端渲染后端用Node或Java生态里的PDF库生成文件再返回给前端下载。优点是后端环境可控字体资源管理方便大数据量内容处理也不怕内存紧张。缺点是增加了前后端联调和接口开发的成本而且需要额外处理接口超时、下载失败、大文件传输这些情况。三者的取舍我用一个表来说清楚对比维度前端内嵌字体DOM转图片服务端渲染实现成本中等一次性配置低上手快较高需双端配合文字可选中支持不支持支持清晰度高矢量文字依赖截图分辨率高文件体积中等受字体大小影响大概率偏大可控适合场景报表、表格、多样式文档快速原型、视觉稿导出复杂盖章、模板合同、大批量导出2.2 我为什么选择前端内嵌字体加autotable组合单看表格数据导出这个需求我的选择非常稳定前端内嵌字体jspdf-autotable。原因不复杂项目里大多数导出都是用户在前端点一个按钮马上要看到.pdf文件落地。如果走服务端要等请求往返还要处理并发增加时的服务端压力如果走DOM转图片用户导出后想从PDF里复制一段文字出来都做不到体验太差了。内嵌字体方案最大的成本在于“第一次配置字体”但这份成本是一次性的。配置好了之后所有表格导出都能复用。配合jspdf-autotable内置的自动分页、表头重复设置做多页表格的效率比手写坐标循环高出一大截。接下来我把这次实操里从字体准备到最终导出的完整链路一步步拆开讲。3. 实操记录从字体准备到表格导出一次跑通3.1 准备一款合适的开源中文字体第一步是准备字体文件。推荐使用开源可商用的中文字体比如思源黑体、思源宋体系列它们采用开放字体许可SIL OFL 1.1允许把字体文件嵌入到PDF等文档中再分发可以放心用在实际项目里。字体格式优先选.ttf。虽然jsPDF官网的示例里有.ttf也有.otf但实际测试下来.ttf格式在各种版本下兼容性更好。下载下来的字体文件可能需要十几兆这个不用慌后面可以瘦身。如果你使用的场景字符比较集中比如导出的是订单数据基本就是数字、字母、常用汉字和中文标点完全可以做一个子集字体后面我会详细说。把字体文件放在项目里之后创建一个字体模块文件比如font.js里面导出字体文件的base64字符串。这一步是整个方案里看起来最笨但最可靠的做法。3.2 把字体文件转成base64的三种做法jsPDF注册字体时需要的不是字体文件的URL而是字体文件的base64字符串。所以得先把.ttf转出来。第一种做法最简单找一个在线的base64转换页面把字体传上去复制生成的字符串。这种方式适合临时验证思路但生成的字符串动不动几百KB甚至几MB塞进项目里非常难看也容易触发编辑器性能问题。第二种做法是提前在Node环境里写好一个转换脚本输出成JS文件。我这里提供一个可以直接用的脚本const fs require(fs); const path require(path); const inputFile path.join(__dirname, assets, SourceHanSansCN-Regular.ttf); const outputFile path.join(__dirname, assets, font.base64.js); const base64 fs.readFileSync(inputFile).toString(base64); const content export default ${base64};; fs.writeFileSync(outputFile, content, utf8); console.log(字体base64文件已生成);运行一次把生成的font.base64.js保留在工程中。之后不管谁接手这个项目都不需要再去了解字体转换的步骤。需要注意的是字符串内部如果包含反斜杠或引号理论上会出问题但base64字符集里没有这两种字符所以可以直接放在双引号里。第三种做法是运行时用fetch去加载一个静态字体文件再转成base64。好处是不用把巨大的字符串写进代码里缺点是用户下载时要多一次网络请求而且跨域会变成一个潜在坑。我建议熟练的人用方案三常规项目直接采用方案二稳妥。3.3 注册字体并输出第一版可用的表格PDF材料准备好之后核心代码其实很简洁。先看一个关键配置addFileToVFS把字体文件内容挂到一个虚拟文件系统addFont注册字体名称setFont切换当前字体。import { jsPDF } from jspdf; import autoTable from jspdf-autotable; import fontBase64 from ./assets/font.base64.js; const doc new jsPDF({ orientation: portrait, unit: pt, format: a4 }); // 第一步注入字体文件 doc.addFileToVFS(SourceHanSansCN-Regular.ttf, fontBase64); // 第二步注册字体第二个参数是字体别名后面要用它来切换 doc.addFont(SourceHanSansCN-Regular.ttf, SourceHanSansCN, normal); // 第三步设置当前字体 doc.setFont(SourceHanSansCN, normal); const head [[订单编号, 客户名称, 商品名称, 订单金额, 下单时间]]; const body [ [HT20240001, 华东某连锁商超, 无线扫码枪, 899.00, 2024-06-01 10:23:11], [HT20240002, 华南某生鲜平台, 电子秤, 1299.00, 2024-06-01 11:02:45], [HT20240003, 华北某仓储系统, 工业扫码枪, 1599.00, 2024-06-01 12:11:30] ]; autoTable(doc, { head: head, body: body, startY: 40, styles: { font: SourceHanSansCN, fontStyle: normal, fontSize: 9, cellPadding: 4 }, headStyles: { fillColor: [52, 73, 94], textColor: 255, fontSize: 10 } }); doc.save(订单导出.pdf);这里有两个细节很多人第一次会忽略。第一addFont的第二个参数最好取一个容易记的英文字符串比如SourceHanSansCN同时它和setFont的第一个参数必须完全一致大小写都不能错。第二autotable的styles里必须显式指定font光靠前面doc.setFont不会自动生效。跑完这一段表格里的中文应该已经正常显示了。如果一切顺利这一步就一锤定音解决了问题。不过实际项目中往往没这么简单数据会变、行数会变、表格可能不止一个这些情况下面的小节里一起处理。3.4 多表格、分页和页码的完整处理真实业务里的导出通常不是只打一个表格。比如客户想下载一个PDF第一页是汇总表第二页是明细表而且希望每一页都有页码。处理多表格时关键在于控制每个表格的起始位置startY。autoTable会在渲染结束后把doc.lastAutoTable.finalY暴露出来这个值表示上一个表格的结束位置。下一个表格的startY可以设为finalY 20留出间距。autoTable(doc, { head: summaryHead, body: summaryBody, startY: 40, styles: { font: SourceHanSansCN, fontStyle: normal, fontSize: 9 } }); const previousTableEndY doc.lastAutoTable.finalY; autoTable(doc, { head: detailHead, body: detailBody, startY: previousTableEndY 25, styles: { font: SourceHanSansCN, fontStyle: normal, fontSize: 8 } });分页这块如果表格内容很多autotable会自动帮我们分页并且默认只在第一页显示表头。如果想在每一页都重复表头可以给autoTable传一个didDrawPage回调里面判断当前不是第一页时重新画表头。页码就更常规了PDF生成后遍历所有页面在右下角输出“第x页/共y页”。同样需要注意字体设置setFont在绘制页码前必须重新指定中文字体否则页码区域可能回退到默认字体。const totalPages doc.getNumberOfPages(); for (let i 1; i totalPages; i) { doc.setPage(i); doc.setFont(SourceHanSansCN, normal); doc.setFontSize(8); doc.text(第 ${i} 页 / 共 ${totalPages} 页, doc.internal.pageSize.getWidth() - 60, doc.internal.pageSize.getHeight() - 20); }这里我习惯用pt作为PDF坐标单位因为fontSize和cellPadding的数值用pt表示更直觉如果打开某些旧项目是用mm就要注意数字比例不一致的问题。4. 常见问题与排查实录4.1 明明设置了setFont表格里的中文还是乱码这个问题我前面预告过它是最典型的一个版本坑和API坑。如果你在调用autoTable之前只是执行了doc.setFont(SourceHanSansCN, normal)然后没有在styles里手动指定font结果往往是普通文本正常、表格乱码。原因是jspdf-autotable构建单元格内容时使用的是自己的样式配置对象它不会自动读取当前PDF实例的字体状态。正确做法是在styles里加一行font配置。如果你用的是比较老的jspdf-autotable 2.x版本还可能是通过doc.autoTable({...})方式调用在3.x版本之后统一改成了直接导入autoTable函数并传入doc实例。升级版本后记得检查API是否匹配否则就算字体配置对了组件可能抛出一个莫名其妙的doc.autoTable is not a function。4.2 字体base64字符串过大打包或运行时内存压力大字体base64字符串如果完整塞入可能接近10MB业务代码的打包体积会明显变大。有两类解决办法。第一类是给字体做子集化只保留可能用到的字符。比如订单导出场景中文字符基本集中在“订单、客户、商品、金额、时间”等常用词和数字标点上。用子集化工具从完整字体里提取一个几千字符的副本体积可以控制到几百KB明显改善编译和加载体验。实践中我常用Python的fonttools库或者用“字蛛”这类前端字体压缩工具来完成。第二类是不要把这个字符串打进业务包而是部署成静态资源运行时通过fetch加载再转成base64喂给jsPDF。这样避免体积进入打包结果但前提是资源服务器允许跨域访问。如果应用本身和静态资源同源没有跨域问题这个方法很推荐。4.3 导出后某些环境里正常某些阅读器显示乱码或空白如果同一个PDF文件在电脑上打开正常在手机里、微信内置浏览器里、或者某个老旧的预览器里出现乱码大概率是字体嵌入不完整或字体格式兼容性差。建议优先使用.ttf格式并且在addFont时把字体样式里涉及的权重都注册一遍。比如如果某些单元格用的是bold而你只注册了normal阅读器可能找不到对应字形。这里可以在初始化时一次性注册多个样式doc.addFont(SourceHanSansCN-Regular.ttf, SourceHanSansCN, normal); doc.addFont(SourceHanSansCN-Bold.ttf, SourceHanSansCN, bold);注册之后autotable的styles里可以用fontStyle: bold指定。如果简单处理尽量所有文本都用normal统一权重能少很多兼容问题。4.4 表格字段太长中文换行挤压导致排版错乱这个问题在导出详情列表时很常见。中文字符没有空格autotable默认会按空格换行但中文整句没有空格容易把单元格撑破。处理办法是给列设置合理宽度并显式指定overflow: linebreak。autoTable(doc, { head: head, body: body, columnStyles: { 2: { cellWidth: 140, overflow: linebreak }, 3: { cellWidth: 70, overflow: linebreak } }, tableWidth: auto, styles: { font: SourceHanSansCN, fontStyle: normal, fontSize: 8 } });加上cellWidth的中文长字段会按这个宽度自动换行不再把表格撑破。如果某一列本身就是长文本描述甚至可以给cellWidth给到页面宽度的一大半再配合minCellHeight保证视觉整齐。我在实际项目里还踩过一个更隐蔽的坑日期时间字段在PDF里显示成一行很长的“2024-06-01 10:23:11”在pt单位下自然宽度超过单元格导致相邻列错位。最后也是在columnStyles里单独指定了这一列的cellWidth才让布局稳定下来。4.5 字体明明注册成功导出后中文变“方框”出现方框说明PDF里确实嵌入了字体但阅读器仍然找不到对应字形的描述。这种情况常见于你拿了一个子集化工具但字符范围没有覆盖正文里的全部字符。比如用户上传了数据里面有个生僻字或繁体字子集字体里根本没这个字形打开就是这个效果。保险做法是子集化时预留比较大的字符范围特别是常用简体中文区GB2312覆盖的几千字基本能应对绝大多数业务数据。如果业务场景涉及名字、地址这种高度不确定的字段就稳妥一点不要贪体积把子集裁剪得太狠至少把Unicode基本汉字区0x4E00-0x9FA5整段保留。这样文件会大几百KB但换来了“怎么导都不会出现方块”的安心感。注意字体文件嵌入后PDF体积会上涨这是正常现象。如果要控制最终PDF大小优先从字体子集化和压缩图片两个方面入手而不是压缩页面里的文本信息。5. 写在最后的一点个人经验这个方案我已经在好几个项目里复用过了每次踩坑后我都会把字体文件、转换脚本、导出封装函数整理成一个公共模块。团队里新同事来做导出功能直接把这个模块复制过去把表头、表格数据换成自己的半小时内就能出活再也不会在中文乱码上耗时间。我个人最大的体会是这类问题看上去是“代码Bug”实际上是“文件格式认知”的问题。只要理解了PDF是自包含文档、必须嵌入字体这个底层的逻辑再遇到类似场景时你第一时间就能判断出解决方向而不是反复试各种玄学写法。最后再分享一个小技巧如果你手头没有合适的开源中文字体文件又不想折腾子集化可以先只做“常用汉字数字标点”的迷你字体验证流程确认整条链路跑通后再替换成正式字体。这样既能把环境问题和技术问题分离开来也不会因为字体文件太大而拖慢整个开发调试节奏。把这一步做好后面换字体几乎只是一行代码的事。