jsPDF中文乱码终极解决方案:字体嵌入、注册与换行分页实战
直接用 jsPDF 导出表格 PDF十次里有八次栽在中文乱码上。更气人的是浏览器里看着好好的下载下来打开全是方块有时候连方块都不是直接变成一堆毫无意义的字符。这篇文章就专门聊怎么把这个问题彻底摁死。我试过的方案、踩过的坑、以及最终落地能直接抄的代码都会放在下面。先说清楚一个反直觉的结论jsPDF 中文乱码根子不在编码而在字体。PDF 文件不像网页能随时调用系统字体它靠的是把字体文件整个嵌进文档里。jsPDF 自带的 14 种标准字体全是拉丁字符集一个汉字都没有。你没给它注册中文字体它就只能对着张三这种文本干瞪眼最后渲染成方框或者乱码线。解决了这个根因之后还有一堆连锁问题要处理字体从哪来、怎么注册、表格宽高怎么算、中文换行怎么断、分页怎么画。这篇文章按实际落地顺序来写从字体准备到完整可用的导出函数一条龙讲透。1. 乱象背后的真实原因PDF 内部的字体机制与 jsPDF 的缺字1.1 两种典型的中文乱码长相乱码其实分两种长相很多人搞混了导致排查方向全错。第一种叫豆腐块也就是方框。特点是你知道那里有个字符但 PDF 里没有对应的字形渲染出来。这时候说明字体注册了部分信息但字形数据缺失或者注册的字体本身没有覆盖这些汉字。第二种叫纯乱码比如张三变成了寮? 或者一串井号、问号。这种情况多半是字体编码表对不上PDF 内部按某套编码去查字形表结果查出来的索引指向了完全不相关的位置。我实测中 80% 的情况是豆腐块因为 jsPDF 默认不包含任何 CJK中日韩统一表意文字字形遇到中文字符直接放弃渲染。另外 20% 是编码混乱通常发生在你手动改过字体子集、或者混用了不同版本的字体文件时。排查的时候先拍一张 PDF 截图放大到 300%看看乱码是方框还是乱字符。这一步能帮你省下大量瞎折腾时间。1.2 PDF 的字体嵌入原理为什么能显示不等于能导出浏览器里网页能显示中文是因为操作系统里装着对应的中文字体浏览器渲染时直接调用系统字库。但 PDF 的逻辑不是这样。PDF 文档有一种机制叫字体子集嵌入。生成 PDF 时把用到的字符从字体文件里提取出来连同字形数据一起塞进 PDF 内部。这样不管你这台电脑上有没有装中文字体打开 PDF 时都能正常显示。jsPDF 的默认 PDF 生成库并不帮你自动完成从系统请一个中文字体进来这件事。它只内置了标准的 PDF 字体比如 Helvetica、Times、Courier。这些字体的字符集非常有限覆盖不了 CJK 区域。所以你要手动告诉 jsPDF加载一个中文字体文件注册成自定义字体然后把内容字体设置切换过去。这里还有一个关键点你注册的字体文件需要能被 jsPDF 完整解析。很多能找到的中文字体比如 Windows 自带的微软雅黑其实是大字重、大体积的商业字体直接拿来用不仅注册耗时还可能因为它特殊的字体表结构在 jsPDF 的解析器上翻车。提示字体文件不是越大越好也不是越全越好。注册进 PDF 后它会整体嵌入文档一个几十 MB 的字体文件足以让你的导出卡死导出的 PDF 也有几十 MB根本没法用。1.3 先给一个能跑通的最小修复样例在你考虑怎么精细调样式之前先让中文显示出来再说。这是一个最小的可运行方案import { jsPDF } from jspdf; // 先拿到字体文件的 base64 字符串 const fontBase64 await fetch(/fonts/NotoSansSC-Regular.ttf) .then((res) res.arrayBuffer()) .then((buffer) { const bytes new Uint8Array(buffer); let binary ; bytes.forEach((b) { binary String.fromCharCode(b); }); return btoa(binary); }); const doc new jsPDF(); doc.addFileToVFS(NotoSansSC-Regular.ttf, fontBase64); doc.addFont(NotoSansSC-Regular.ttf, customFont, normal); doc.setFont(customFont); doc.text(你好世界, 20, 30); doc.save(test.pdf);这个样例的精华在addFileToVFS和addFont这两行。前者把字体文件挂到虚拟文件系统后者注册出一个名字叫customFont的字体。之后所有setFont(customFont)之后的文字都会走这个中文字体渲染。2. 中文字体文件的获取、筛选与预处理2.1 为什么我建议你优先选择思源系字体注册中文字体最省心的选择有两个方向思源黑体Noto Sans SC和思源宋体Noto Serif SC。前者适合报表、数据展示、商务文档后者适合偏文档、合同正文的场景。选思源系主要是安全和体积的平衡思源系是基于开源许可证OFL 1.1发布的直接进商用项目没有授权雷区。微软雅黑是商业字体不太适合嵌进你导出的 PDF 里分发。思源系的字体表结构相对标准jsPDF 在 2.x 版本解析它没出过幺蛾子兼容性最好。思源变体多官方提供了大字体包和分地区子集版本方便你做裁剪。实际项目里我遇到过直接用某商业中文字体注册后英文数字全部偏移、中文偶尔缺字的情况。换成思源黑体之后马上清爽。字体解析器对不同字体文件结构的容错能力差别很大选一个主流开源字体是最稳妥的。2.2 通用做法用 fontmin 按需裁剪而不是整个塞进去很多人一开始很开心地把一个 10MB 的 TTF 文件转成 base64一跑页面卡死了。base64 会让文件体积膨胀约 33%再加上 jsPDF 要把字体解析出来嵌入 PDF一次性处理几 MB 的字体浏览器主线程直接堵死。我的做法是先用 fontmin 这类工具把字体文件裁剪到只包含自己业务里用到的那部分字符。npx fontmin --text用户姓名电话日期商品金额数量操作合计张三李四... NotoSansSC-Regular.ttf output.ttf裁剪前先统计一下项目里会进 PDF 的中文字符把常见报表词、表头词、月份词、金额单位都加进去。裁剪后的文件通常能压到 20KB-200KB导出速度和 PDF 体积都会有质的提升。2.3 TTF 与 WOFF 的取舍优先 TTFjsPDF 官方文档里提到的字体格式主要是 TTF。WOFF/WOFF2 虽然体积更小但 jsPDF 的解析能力在不同版本上有过兼容问题我在 2.5.x 上测试过部分 WOFF 文件注册后出现渲染异常。如果你的网络字体源只有 WOFF也不是完全不能用但建议用https://transfonter.org/这类在线工具转成 TTF 再注册。而且要转成 TTF 格式不是 OTF。jsPDF 对 OTF 的支持也比较弱遇到 CFF 表结构的 OTF 很容易渲染出错。3. jsPDF 自定义字体的三种注册方式与各自的坑3.1 直接加载网络字体 URL最简洁但要考虑跨域jsPDF 2.x 的addFont支持直接传 URLconst doc new jsPDF(); await doc.addFont(https://your-cdn.com/fonts/NotoSansSC-Regular.ttf, customFont, normal); doc.setFont(customFont); doc.text(中文测试, 20, 30);这背后的逻辑是 jsPDF 内部用 fetch 拉取字体文件内容并转换编码。看起来很完美但有两个问题跨域限制如果你的站点和字体所在的 CDN 不在同一个域且 CDN 没有返回Access-Control-Allow-Originfetch 直接失败。动态字体加载的时序问题addFont是异步的如果你在 await 之前就调用text()画内容那画出来的还是默认字体中文照样乱码。所以用这种方式时务必保证 await 完成后再进行任何绘制。如果字体部署在自己的域名下这个方式最省事。3.2 本地 base64 注册最稳妥可控性最强我项目里最常用的是把字体文件转成 base64 字符串打进代码里静态引用。这种方式的优势是可控性强、和网络无关、也不会有加载时序问题。字体转 base64 的脚本const fs require(fs); const fontBuffer fs.readFileSync(./NotoSansSC-Regular.ttf); const fontBase64 fontBuffer.toString(base64); fs.writeFileSync(./font-base64.txt, fontBase64);然后在页面里引入这个 base64 字符串或者在构建时通过环境变量注入注册方式就是上面最小样例的做法const doc new jsPDF(); doc.addFileToVFS(NotoSansSC-Regular.ttf, FONT_BASE64); doc.addFont(NotoSansSC-Regular.ttf, customFont, normal); doc.setFont(customFont);这里有个细节addFont的第二个参数是字体别名第三个参数是字重样式。我一般固定写normal。千万别同时注册三个字重normal/bold/italic否则会让 PDF 体积翻倍。大多数表格场景一套正常字重就够了。注意如果你注册了多个字体setFont时必须保持完全一致。很多人在addFont时用了chinese然后setFont(Chinese)大小写不一致字体切换就失效了然后又开始怀疑人生。3.3 用户上传字体动态注册给特殊需求留的后门有一个场景比较特殊用户希望用自己的字体导出信封、请柬之类的 PDF。这时候可以在页面上加一个input typefile accept.ttf让用户选字体文件然后用 FileReader 读取并转 base64const reader new FileReader(); reader.onload (e) { const base64 e.target.result.split(,)[1]; const doc new jsPDF(); doc.addFileToVFS(user-font.ttf, base64); doc.addFont(user-font.ttf, userFont, normal); doc.setFont(userFont); doc.text(自定义字体测试, 20, 30); doc.save(custom.pdf); }; reader.readAsDataURL(file);这种方式比较少见但知道有这个路子遇到定制化需求不至于抓瞎。核心原理还是一样把字体内容以 base64 形式送进 jsPDF 的虚拟文件系统。4. 表格数据导出的完整实现从数据到 PDF 报表4.1 确定表格的列宽、起始坐标与基础参数在真正画表格之前先规划好坐标系统。PDF 的坐标系原点在左上角单位是 pt点1pt 约等于 0.3528mm。A4 纸的宽度是 595.28pt高度 841.89pt。我的默认页面设置是左边距 20pt右边距 20pt内容可用宽度 595.28 - 40 555.28pt顶部起始 y 坐标 30pt给标题留出空间假设导出的表格有 5 列列宽分配要基于内容特性。我不建议每列等分因为姓名金额操作这种列的宽度需求差异极大。一个常用的列宽定义方式const columns [ { header: 姓名, key: name, width: 80 }, { header: 联系方式, key: phone, width: 120 }, { header: 购买商品, key: product, width: 160 }, { header: 金额, key: amount, width: 80 }, { header: 购买日期, key: date, width: 90 }, ]; const tableLeft 20; const tableWidth columns.reduce((sum, col) sum col.width, 0);计算表格宽度和 A4 可用宽度做比对如果超了就等比缩放列宽。4.2 表头绘制与单元格背景填充表头是整张表格的脸面。实现时用setFillColor和rect画底色再用text写字doc.setFillColor(240, 244, 248); doc.rect(tableLeft, startY, columns.reduce((s, c) s c.width, 0), 24, F); let colX tableLeft; columns.forEach((col) { doc.setFont(customFont); doc.setFontSize(10); doc.setTextColor(51, 51, 51); doc.text(col.header, colX 6, startY 15); colX col.width; });这里的小细节是rect的最后一个参数F表示填充模式。如果你不传这个参数默认是描边表头会变成一个空心框字写在框里但背景色没出来观感差很多。表头高度我设为 24pt能保证 10pt 字号的文字垂直方向有足够的呼吸空间。text的第二个参数是文字左下角坐标不是中心。所以要让文字垂直居中需要做偏移常见做法是y 行高/2 字体高度/4。例如表头高 24pt字号 10pt行高约 13.6pt垂直居中偏移大约是24/2 3.4 15.4所以写成startY 15是合理的。4.3 表体数据行渲染带边框、隔行变色、行高自适应画完表头开始画数据行。我通常会先算好每一行的实际高度再统一绘制。因为有的单元格文字长需要折行折行后的行高会变大。如果还用固定行高文字就会溢出边框。const drawTableRow (rowData, y, rowHeight, isEven) { if (isEven) { doc.setFillColor(249, 250, 252); doc.rect(tableLeft, y, tableWidth, rowHeight, F); } doc.setDrawColor(220, 225, 232); doc.line(tableLeft, y, tableLeft tableWidth, y); let colX tableLeft; columns.forEach((col) { const rawValue rowData[col.key] ?? ; const lines customSplitText(rawValue, col.width - 12); doc.setFont(customFont); doc.setFontSize(10); doc.setTextColor(34, 34, 34); lines.forEach((line, index) { doc.text(line, colX 6, y 14 index * 14); }); colX col.width; }); doc.line(tableLeft, y rowHeight, tableLeft tableWidth, y rowHeight); };隔行变色用的是isEven参数来控制底色。真做的时候可以维护一个计数器第偶数行填充浅灰奇数行保持白色。这个对比度画出来之后表格会好读很多。提示不要在一行里像画表头那样用rect直接填充整行底色然后还指望用line画分隔线时线不会被底色盖住。rect的填充模式和line的绘制模式是独立的所以先填充、再画线没问题。4.4 用图片方案绕过字体问题可以但代价不小这里顺便说说网上的另一种热门方案用 html2canvas 把 DOM 表格截图成图片再塞进 PDF。这个方案在中文一定不乱码这个点上确实无脑可靠因为渲染出来的就是图片不存在字体缺失问题。但它有两个非常实际的代价生成的是图片PDF 里的文字不可选中、不可搜索、不可复制。如果你导出的表里有关键数据用户想复制某个单号做不到。图片体积大。一张 A4 图片像素按 150dpi 算也得 700x1000 左右一张 PNG 随便都几百 KB几十页的报表下来PDF 体积感人。所以如果你只是做看得过去的临时导出而且表格样式复杂大量渐变、阴影、圆角可以考虑 html2canvas 方案。但如果你要的是正规、可搜索、数据量大的业务报表老老实实把 pdfmake 或 jsPDF 的字体方案解决掉长期收益更大。5. 中文自动换行与分页乱码之外的硬骨头5.1 splitTextToSize 为什么会让中文断错行前面提过doc.splitTextToSize(text, maxWidth)是 jsPDF 自带的文本分行工具。但它默认是按空格和英文单词来断行的中文文字之间没有空格它就会把一长串中文当做一个超长单词然后干出两件蠢事一个单元格里全是中文它不给你断行直接溢出单元格边界。它按西文单词断行后行尾可能留着半个词中文瞬间出现奇怪的换行点。网上有一种说法是用 splitTextToSize 就完事了这说法不准确至少对中文场景不成立。得配合自定义逻辑。5.2 一个实用的自定义中文断行函数我的方案是先把长字符串按字符切分然后逐个追加字符用doc.getTextWidth()测量宽度超过可用宽度就换行。这个逻辑相当于自己实现一遍贪心算法分行function customSplitText(doc, text, maxWidth) { const lines []; let currentLine ; for (let i 0; i text.length; i) { const char text[i]; const testLine currentLine char; const width doc.getTextWidth(testLine); if (width maxWidth currentLine) { lines.push(currentLine); currentLine char; } else { currentLine testLine; } } if (currentLine) { lines.push(currentLine); } return lines; }注意传入的maxWidth要用col.width - 12因为单元格左右各留了 6pt 的 padding。这是一个很常见又很容易漏掉的细节少了这 12pt 的收窄最后一行文字总会贴到边框上。使用时的调用顺序也很重要在setFont(customFont)和setFontSize(10)之后再调用getTextWidth否则测量的字体还是默认 Helvetica宽度是错的断行位置也会一起错。5.3 分页到底怎么画分页后表头重复与起始坐标重置数据多的时候一页放不下这时候必须手动分页。jsPDF 不会自动帮你分页text()画到页面边界外也不会报错只会让内容被裁掉或者画到第二页的不可见区域。我的做法是维护一个currentY变量在每次画完一行之后检查它是否超过了当前页面的内容底部const pageHeight doc.internal.pageSize.getHeight(); const bottomMargin 30; const contentBottomY pageHeight - bottomMargin; // 画某一行之前判断 if (currentY rowHeight contentBottomY) { doc.addPage(); // 新页里重绘表头 drawTableHeader(doc, 30); currentY 30 HEADER_HEIGHT; }分页后表头重绘这步别漏。大多数规范报表第二页开始都应该重复表头否则读者翻页后就不知道哪一列是什么了。重绘表头时起始 y 坐标重新设为 30但列宽、颜色这些参数保持不变。5.4 字号选择的小技巧10pt 是报表舒适区12pt 偏大9pt 偏密有段时间我为了在一页里塞更多行把字号调成 8pt结果打印出来看得人眼睛发酸。后来反复对比10pt 是最适合线上预览和线下打印的字号——A4 上字足够清晰纸张利用率也不差。如果你非要塞更多行优先压缩行高而不是压缩字号。行高从 20pt 压到 16pt视觉上还算能接受再低就会显得像密密麻麻的代码。与之对应的单元格 padding 也可以从 6pt 收到 4pt。6. 实操中遇到过的一些离奇情况与最终排查清单6.1 乱码现象的区分与快速定位以下是实战中遇到的一些现象和排查方向现象原因处理办法所有中文都是方框没有注册中文字体或注册后未setFont确认addFont执行成功并在绘制前调setFont(customFont)只有部分罕见字乱码字体子集裁剪时漏掉了这些字把这些字加入 fontmin 的文本列表重新生成字体中文正常但标点偏移字体文件是 OTF 或 WOFF 转换得不干净改用标准 TTF或用 transfonter 重新转换导出的 PDF 体积巨大注册了多个字重或整个字体没有裁剪只保留一个normal字重的字体按需裁剪字符集页面卡死base64 字体太大裁剪字体减小加载体积字体注册放在 Worker 里执行文字能显示但选中复制出乱码字体映射表损坏换一个字体源重新生成这张表里的每一条我都在真机上踩过。特别是部分字乱码这个情况最隐蔽它在开发环境里因为数据量小根本不会暴露一上生产、用户导出一份包含生僻字姓名的大表马上崩。6.2 生成 PDF 前的数据预处理把脏数据挡在外面导出表格 PDF 时数据里常见的坑不比字体少。比如你的数据里可能混着undefined、null或者带有%、这类符号。jsPDF 对纯数字和纯中文还好但遇到undefined会直接报错。所以真正生产级的导出数据进单元格之前必须做一次清洗const safeText (val) { if (val null || val undefined) return ; if (typeof val number) { return Number.isNaN(val) ? : String(val); } return String(val); };另外数字格式化也很重要。金额列如果直接String(12345.6)导出来长这样12345.6不专业。我会统一格式化const formatMoney (val) { if (val null || val undefined) return ; const num Number(val); if (Number.isNaN(num)) return ; return num.toLocaleString(zh-CN, { style: currency, currency: CNY, minimumFractionDigits: 2, }); };处理完再进表格导出的 PDF 直接能用完全不需要人工二次粘贴整理。6.3 要不要换 pdfmake我的建议说句公道话如果你是从零开始的新项目、没有强依赖 jsPDF 的定制需求pdfmake 确实更省心。它内置了对中文文档更友好的字体注册方式并且自带流式布局与自动分页开发者不需要自己写上面那一堆坐标计算、断行逻辑、分页判断。但如果你已经有一段 jsPDF 的导出代码了或者项目中其他页面也用 jsPDF统一保持一套方案是对的。jsPDF 的优势是自由度极高——你可以完全控制每一个像素的绘制这对复杂报表设计很重要缺点就是它把你完全当成一个会造轮子的人。我的建议是报表样式固定、数据量中等继续用 jsPDF 没问题频繁变化、带多级表头嵌套、大量动态字段可以考虑引入 pdfmake 按新需求重写那种改来改去换来换去的最费功夫。如果你决定切换pdfmake 的中文字体注册姿势一般长这样const pdfmake require(pdfmake/build/pdfmake); const vfsFonts require(pdfmake/build/vfs_fonts); pdfmake.addVirtualFileSystem(vfsFonts); // 使用内置的 Roboto 之外还需把中文字体挂到 vfs但要注意pdfmake 默认的 Roboto 同样不含中文还是需要额外注册字体文件。所以不管哪个库中文字体这一步都躲不掉。最后说点务实的话jsPDF 中文乱码问题的核心套路总结下来就三件事准备一个靠谱的 TTF 中文字体、用addFont注册但别漏掉setFont、再处理换行与分页。每次有新同事接手这类导出任务时我都让他们先把最小字体注册 Demo跑通再往里面加表格逻辑绝对事半功倍。有一点我特别想强调字体注册的时机要卡准。实测中如果你在循环里反复切换字体、反复调addFileToVFS导出速度会断崖式下降。正确做法是全局只注册一次后续所有绘制都复用同一个字体别名。最后分享一个小技巧开发时在浏览器里打开导出的 PDF如果觉得字模糊用放大镜工具放大到 200%能明显区分出是字体没嵌入方框还是字体嵌入后编码映射错误乱字符。前者加字体后者换字体文件几秒钟就能定位是哪类问题不用再层层翻日志猜原因。