拓冰建站拓冰建站
首页 / 资讯中心 / 正文

jsPDF导出PDF中文乱码?三步嵌入中文字体彻底解决

1. 乱码的真相不是编码问题是PDF里根本没有中文字形上个月我给内部报表系统补导出PDF功能前端用jsPDF英文页码和数字一直正常一旦出现中文导出的PDF就是一行行□□□。我当时的第一反应和大多数人一样字符串的编码出问题了。于是我把内容做了各种转码尝试折腾了大半个下午结果一点用没有。后来翻到jsPDF相关issue才彻底反应过来这根本不是编码乱码而是PDF字体里没有中文字形。PDF的文本显示依赖字体这一点和网页、Word文档没有本质区别。jsPDF内置的14种PDF标准字体Helvetica、Times、Courier等都只覆盖拉丁字符集字符表里根本没有汉字。当你调用doc.text(你好, 10, 10)jsPDF会把字符串按UTF-16BE编码写进PDF内容流然后根据当前选中字体的cmap表去查找字形。Helvetica里找不到你和好对应的glyphPDF阅读器只能用.notdef占位符渲染也就是你看到的那排方框。这和网页设置了font-family但系统里没有对应中文字体时显示豆腐块是同一个道理。想明白这一层后面所有方案都是围绕一件事让jsPDF嵌入一套真正包含中文字形的字体文件并在生成文档时手动选中它。所以别再去搜索把字符串转成GBK那是在错误的方向上打转。先把这个观念掰过来接下来的三步才有意义。1.1 三种典型乱码形态先分清你属于哪一种我根据实际项目反馈把jsPDF中文乱码分成三类因为排查路径完全不同方框□□□字体缺少中文字形最常见。原因基本是没嵌入中文字体或者嵌了但最后没执行setFont。问号或空白多半是字体文件加载失败、base64字符串被截断、字体文件本身损坏。文字能显示但换行错乱或挤成一团字体已注册成功但宽度计算、换行策略没有针对中文处理。乱码表现最可能原因快速判断方法一整段中文全是方框没注册字体/没setFont检查addFileToVFS、addFont、setFont是否都执行个别字符是方框字体子集化漏字打开PDF看缺的字符对比子集范围文字空白或PDF报错base64损坏/字体格式不被支持单独输出base64长度换TTF测一遍中文能显示但错行splitTextToSize前没setFont调整顺序后重测只有表格里乱码autoTable缺styles.font在表格配置里显式指定字体这张表我反复验证过基本能覆盖多数情况。后文每一步都会解释清楚为什么这么处理。1.2 为什么网上很多转GBK编码方案是死路搜索jsPDF 中文乱码时能看到不少老帖子建议把字符串转成GBK再传进去。这个说法放在早期网页开发里还有一定语境但对生成PDF完全不适用。jsPDF的doc.text接收的是JavaScript字符串内部统一转成UTF-16BE写进PDF内容流再依赖字体cmap表完成字符到字形的映射。如果你手动把字符串转成GBK字节串jsPDF会把这些字节当成普通Latin-1字符逐个解析输出出来只会更乱。换句话说这套流程里字符编码其实没有太大问题问题永远出在字形缺失这一环。所以正确路线只有一条给jsPDF嵌入一个覆盖目标字符集的TrueType字体。下面三步就是这条路线的最小闭环。2. 第一步准备一个能嵌入的中文字体文件并转成base64所谓3步搞定第一步是准备字体并把它转换成jsPDF能消费的格式。这一步看起来简单实际有很多细节决定后面会不会重新踩坑。2.1 选字体的三个现实约束格式、体积、版权先泼一盆冷水不是所有字体文件都能被jsPDF正常嵌入。jsPDF的字体解析器基于TrueType的glyf/loca表结构工作所以TTF最稳闭眼选。OTF不一定行。如果OTF内部用的是PostScript轮廓CFFjsPDF读不出字形数据需要用工具转成TTF再用。WOFF/WOFF2不推荐直接用。虽然部分场景下能解析但为了减少不确定性建议统一转回TTF。体积是第二个约束。全量思源黑体/思源宋体的TTF动辄8-10MB转成base64后字符串还会膨胀约三分之一。生成PDF时浏览器要处理这么长的字符串页面会明显卡顿产出的PDF文件也会大得离谱。所以要么选体积更小、覆盖常用字的开源字体要么对字体做子集化只保留业务需要的那部分字符。第三个约束是版权。中文字体授权是个隐形雷尤其给客户交付商业项目时更需要确认许可。优先选择开源可商用字体比如思源黑体OFL协议、思源宋体OFL协议、阿里巴巴普惠体、站酷系列等。个人练手无所谓但商业化交付前请务必看一眼授权条款。2.2 把TTF转成base64的三种工程化方式jsPDF注册字体需要的是一个文件内容最常见形式是base64字符串。addFileToVFS会把它写进jsPDF内置的虚拟文件系统之后addFont才能引用。那这个base64字符串怎么来我推荐三种方式按项目形态选。方式一命令行直接转。适合一次性准备把结果存成一个静态模块。Linux/MacOS下base64 -w 0 NotoSansSC-Regular.ttf font.b64Windows PowerShell下[Convert]::ToBase64String([IO.File]::ReadAllBytes(NotoSansSC-Regular.ttf)) | Set-Content font.b64然后把font.b64的内容放进一个font.ts或font.js文件里导出。缺点也很明显base64文件巨大编辑器可能卡。字体特别大时不建议直接贴源码。方式二运行时fetch再转base64。适合浏览器应用把TTF当普通静态资源放服务器页面初始化时拉取转换async function fetchFontAsBase64(url: string): Promisestring { const res await fetch(url); const buffer await res.arrayBuffer(); const bytes new Uint8Array(buffer); let binary ; const chunk 0x8000; for (let i 0; i bytes.length; i chunk) { binary String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i chunk))); } return btoa(binary); }注意不要用String.fromCharCode.apply(null, bytes)一次性拼完整Buffer几MB的字体在低端设备上很容易栈溢出或卡顿。分块虽然看起来笨但胜在稳定。方式三在构建工具里预打包。Vite项目可以直接用?url拿到静态资源URL再运行时fetchimport fontUrl from ./assets/NotoSansSC-Regular.ttf?url; const fontBase64 await fetchFontAsBase64(fontUrl);大型项目建议把加载字体注册字体封装成一个可复用的Promise避免每个页面重复处理。2.3 一次性把大TTF做子集化的实操命令如果导出内容以固定文案为主强烈建议做子集化。我用Python的fonttools最多pip install fonttools pyftsubset NotoSansSC-Regular.ttf --text你好世界jsPDF中文显示... --output-filesubset.ttf--text只保留列出的字符如果文本是动态的可以改用--text-fileall_strings.txt提前把所有可能出现的字符串收集进一个文件。想保留全部常用汉字和全角标点就指定Unicode区间pyftsubset NotoSansSC-Regular.ttf --unicodesU4E00-9FFF,UFF00-FFEF --output-filesubset-cjk-common.ttf这样能保留汉字基本区加全角标点体积一般能压到1-2MB甚至更小。但一定记住动态用户输入如果超出子集范围超出的字会重新变回方框。所以子集化之前要先盘点文案范围如果内容完全不可控就老老实实上全量字体或转图片方案。3. 第二步把字体装进jsPDF——addFileToVFS与addFont的正确用法字体文件准备好后第二步是注册。很多新手只调用addFont不调用addFileToVFS或者反过来然后一脸问号。其实这两个方法的分工完全不一样理解它们的关系才能避免各种玄学问题。3.1 VFS和字体字典两个完全不同的概念addFileToVFS(filename, data)是把文件内容写进jsPDF内部的虚拟文件系统它的职责只是存文件。可以理解为在PDF生成的沙箱里放了一个字体文件。addFont(filename, fontName, fontStyle)则是告诉jsPDF有个字体叫fontName它对应VFS里的哪个文件属于normal/bold/italic中的哪种样式。只有两步都做了PDF生成器才能定位到字体文件并解析出真实的字形数据。官方推荐写法jsPDF 2.x如下const doc new jsPDF(); doc.addFileToVFS(subset.ttf, fontBase64); doc.addFont(subset.ttf, MyFont, normal);在较新的jsPDF版本里addFont也支持直接接收ArrayBuffer或Uint8Array但跨版本兼容性最好的仍然是先addFileToVFS再addFont。我建议把这两个调用封装成一个工具函数因为一个页面上经常要多次导出每次重复写容易漏。3.2 最简可运行代码普通文本、粗体与多字体注册一个最小可运行例子import { jsPDF } from jspdf; import { fontBase64 } from ./font; const doc new jsPDF(p, pt, a4); doc.addFileToVFS(subset.ttf, fontBase64); doc.addFont(subset.ttf, MyFont, normal); doc.setFont(MyFont, normal); doc.text(你好世界, 40, 50); doc.save(hello.pdf);注意setFont这一行不能省。注册完字体不代表PDF会自动用它jsPDF默认字体仍然是Helvetica。setFont的第二个参数是字体样式必须和注册时保持一致。如果需要粗体要单独注册一个真正的粗体字体文件doc.addFileToVFS(subset-bold.ttf, boldBase64); doc.addFont(subset-bold.ttf, MyFont, bold); doc.setFont(MyFont, bold);这和你平时用CSS时同一个字库自动加粗的体验不同jsPDF不会自动派生粗体必须提供粗体字形文件。如果项目里黑白宋体混排给不同字体起不同名字比如NotoBlack、NotoSong分开注册即可。在jsPDF 2.5.x里addFont还有第四个参数fontWeight可以支持同一字体名下的多字重注册比如doc.addFont(file, MyFont, normal, 700)但基础场景用style区分就够了。3.3 注册不生效的三个隐性原因我实际排查下来注册了还是乱码90%集中在三种情况字体名不一致。addFont第二个参数叫MyFontsetFont里却写成myfont或My Font。jsPDF匹配字体名是精确匹配写错不会报错只会默默退回默认字体。style不匹配。注册时写了normal页面设置setFont(MyFont, bold)此时没有bold字体同样会退回默认字体。base64被截断。尤其是从txt文件粘贴时编辑器自动换行会在base64中间插入换行符。base64字符串里不能有任何换行和空格粘贴后最好确认一下或者用正则把无关空白先清掉。4. 第三步在text、splitTextToSize和autoTable里让中文按预期排版字体能正常显示后完美输出还差排版这一步。中英文混排和纯英文排版有几个明显差异处理不好就会变成另一个形态的乱码。4.1 中文不会自动换行maxWidth和splitTextToSize的坑jsPDF的doc.text(text, x, y, { maxWidth })确实支持maxWidth参数但它的换行策略是按空格切分。中文句子整段没有空格直接传maxWidth要么一行溢出页面要么换行位置完全不可控。可靠做法是先用splitTextToSize把长文本切成行数组doc.setFont(MyFont, normal); const lines doc.splitTextToSize(这是一段很长很长需要自动换行的中文内容测试一下效果。, 180); doc.text(lines, 40, 50);这里有一个顺序陷阱必须先setFont再splitTextToSize。切行时底层要调用当前字体的宽度测量能力如果当前还是Helvetica中文宽度测出来是错的切出来的行会忽长忽短。我见过有人把font设置写在切行之后结果是前两行正常、后面越排越乱排查半天才发现是顺序问题。4.2 表格场景jspdf-autotable忘记配font也会乱码很多报表不是纯文本而是表格。jspdf-autotable是常用插件但它的默认样式仍然使用jsPDF默认字体。如果不显式指定注册好的中文字体表格里所有中文都会变成方框。正确写法import autoTable from jspdf-autotable; autoTable(doc, { head: [[姓名, 城市]], body: [ [张三, 上海], [李四, 成都], ], styles: { font: MyFont }, });styles.font要填addFont时起的字体名不是文件路径。如果表格头、正文、页脚想用不同字体比如标题黑体、正文宋体可以在headStyles、bodyStyles里分别覆盖。这个配置项在官方文档里不算显眼但我身边已经不止一个同事栽在表格中文乱码上了。4.3 行高、对齐、全角标点决定完美输出的细节嵌入中文字体后doc.getTextWidth能正确测量宽度但高度控制还是要自己算。doc.text的y参数默认是文字基线多行文本循环写时建议按字号乘以1.4到1.6倍作为行距而不是固定写死y 14let y 50; const fontSize 12; const lineHeight fontSize * 1.5; for (const line of lines) { doc.text(line, 40, y); y lineHeight; }另外全角标点是否覆盖也要留意。很多子集化命令只保留了汉字区把全角逗号、句号、括号漏掉了。结果就是汉字能显示但中文标点还是方框。子集化时显式包含UFF00-FFEF全角区段能省掉后续很多麻烦。5. 从乱码到完美输出一份可收藏的排查清单和后备方案最后把经验收敛成一份可以对着检查的清单。这部分内容比较杂但每一条都来自真实踩坑希望对你有直接帮助。5.1 按输出结果快速定位问题环节输出表现优先怀疑检查步骤中文字符全是方框没注册字体或没setFont确认addFileToVFS、addFont、setFont三步都执行且字体名完全一致部分字符是方框字体子集化漏字检查子集化的--text/--unicodes范围临时换全量字体验证报错或空白字体格式不支持/base64损坏确认是TTF而不是CFF OTF检查base64是否完整且无换行能显示但换行错乱splitTextToSize前未setFont调整字体设置顺序重测getTextWidth表格中文乱码文本正常autoTable缺styles.font在表格config里显式设置styles.font这张表虽然简单但定位效率很高。我自己排查时通常先判断文本区域乱码还是表格区域乱码直接缩小到对应配置。5.2 我踩过的几个真实坑第一个坑是字体名大小写。我用addFont注册了NotoSansSC后来为了统一CSS命名习惯把setFont写成了notosanssc代码不报错PDF中文全变方框排查了半小时。第二个坑是粗体漏注册。项目里标题用了setFont(MyFont, bold)但我只注册了normal文件结果标题乱码、正文正常特别有迷惑性。第三个坑和开发环境有关。字体base64存在一个很大的常量文件里工程热更新偶尔会加载到旧的截断版本导致测试PDF时好时坏。后来我把字体加载封装成异步函数每次页面初始化都走fetch拿真实文件现象才稳定下来。第四个坑是外部资源依赖。项目如果要在用户浏览器里运行时fetch字体最好把字体文件放到自己的服务器或对象存储避免因公共CDN不稳定导致今天能导出明天不能。这个坑在跨国项目里尤其明显不过那是另一篇长文了。5.3 实在不行时的两条退路如果项目场景确实极端——动态内容太多、字体子集化不可控、或者不允许额外请求——可以考虑两条退路。第一条是改用html2canvas先截屏再入PDF。把包含中文的DOM节点渲染成Canvas再用doc.addImage塞进PDF。中文字形由浏览器完成渲染完全绕开jsPDF字体嵌入问题。代价是文字不可选中、PDF体积变大、清晰度受设备像素比影响。适合对文字可复制性没有要求的单据、分享图类导出。第二条是服务端生成PDF。用Puppeteer打开一个带样式的HTML模板page.pdf()输出PDF中文字体由Chromium完整渲染动态内容也不需要前端注册字体。这套方案对中英文混排、表格、分页都很友好只是从纯前端同步导出变成异步任务交互上要多做一层进度反馈。我现在的默认做法是项目里封装一个工具函数把字体加载、注册、子集化检查全部收拢业务方只需要传字符串和坐标。这套流程最初也踩了两天但封装完之后后面所有页面导出PDF再没被中文放过鸽子。如果你也在jsPDF上被中文折磨建议先按第二节准备一个子集化TTF跑通最小样例再叠加表格和换行逻辑。等这套流程稳定了你会发现3步搞定这一步一步走下来其实比想象中更顺。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门