☰
pdfmake导出PDF中文乱码?中文字体打包与注入完整指南
2026/10/13 7:51:28 网站建设 项目流程

做前端导出PDF时,用pdfmake遇到中文乱码,基本上每个入坑的人都会撞上一次。界面表格渲染得整整齐齐,一按导出,PDF里中文全变成长方块和问号,英文数字倒是一切正常。这个现象不是pdfmake随机抽风,而是它的字体机制决定了:默认内置的Roboto字体根本不认识中文。这篇文章就围绕pdfmake打包导入中文字体、解决中文乱码这件事,把从字体选型、打包到代码注入的全流程拆开讲清楚。

内容适合两类人:一类是第一次在项目里集成pdfmake、刚被中文乱码卡住的新手;另一类是已经接过这类需求、想把手里的方案换成更稳定更省体积的做法的开发者。看完可以照着操作直接复现,也能理解为什么这样操作。

1. 先搞清楚pdfmake为什么会对中文“失明”

1.1 字体嵌入机制是乱码的根源

pdfmake在生成PDF时,并不会去调用你操作系统里装好的字体。它有一套自己的“虚拟文件系统”(vfs),所有用到的字体文件都必须提前打包进这个vfs里。生成文档时,pdfmake从vfs里读取字体文件,把字体数据嵌入到PDF文件中,然后再按字体里定义的字形(glyph)去绘制每一个字符。

这意味着PDF的可移植性和字体文件的完整性是强绑定的。PDF本身是自包含文档,你在自己电脑上装了某个字体,不代表打开PDF的人也有这个字体。为了确保任何设备上都能显示一致,pdfmake选择把字体打包进产物里。思路本身没毛病,但问题就出在它默认带的那套字体上。

如果你用pdfmake的默认配置直接生成PDF,它只会在vfs里找到Roboto系列字体。Roboto服务拉丁字母、希腊字母、西里尔字母都没问题,遇到CJK(中日韩统一表意文字)就不行了。可以类比成:你让一个只认识英文字母的排版师傅去排中文报纸,他手里没有汉字字模,印出来的当然只能是空格和方框。

1.2 默认Roboto字符集的边界

Roboto是Google设计的无衬线字体,字符集覆盖范围主要面向西文字形,包括拉丁扩展、希腊、西里尔等。对于汉字所在的CJK统一表意文字区(U+4E00到U+9FFF),Roboto根本没有对应的字形定义。

pdfmake在绘制PDF文本时,遇到Roboto里找不到的字形,并不会主动去别的字体里借字形,而是直接输出一个“缺字”的占位符。不同PDF阅读器对这个占位符的渲染不一样,有的显示成空方块(豆腐块),有的显示成问号,有的干脆什么都不画。这就是同一份文件在不同设备上打开,乱码表现还不完全一样的原因。

所以,想解决中文乱码,核心思路就一条:给pdfmake准备一份覆盖中文字符集的字体文件,把它打包进vfs,然后在文档定义里明确指定使用这份字体。后面所有操作都是围绕这件事展开的。

2. 准备中文字体:选型、子集化与格式转换

2.1 字体选型与版权注意事项

先解决用什么字体的问题。中文字体选择范围其实不小,但要同时满足“字符集覆盖全”“文件体积可接受”“版权安全”这几个条件,选择就收窄了很多。

我的习惯是优先用思源黑体(Source Han Sans)和思源宋体(Source Han Serif)这类开源字体。思源系列支持多种字重,字符集覆盖完整,开源授权允许商用,是很多项目默认的字体方案。如果你的文档偏正式一点,比如合同、公文类导出,宋体更合适;如果是普通报表、数据分析结果,黑体在屏幕上更清晰、打印也不糊。

有一点需要特别提醒:版权问题在真实项目里吃过亏。早期很多人图省事直接打包系统自带的微软雅黑、宋体等字体,这些字体的授权并不宽松,商用场景下有风险。现在主流做法已经比较清晰了——优先使用开源中文字体,用得安心,也方便团队内部统一管理。如果你所在公司有专门的设计规范,直接按公司规定的字体来,别自己拍板。

2.2 用pyftsubset做字符集裁剪

选中字体之后,大多数人的第一反应是把整份字体文件丢进工程。中文字体动辄10MB起步,直接打包进前端资源会拖累页面加载速度。这里就需要做一步关键处理:字体子集化。

子集化的原理很简单:从完整字体里只抽取你实际用到的字符,生成一个精简版字体文件。比如一份PDF报表里可能只用到几百个汉字,那就没必要保留两万多个汉字字形。用Python的fonttools工具可以快速完成:

pip install fonttools brotli # 准备一个文本文件,把所有需要的中文、数字、符号收录进去 pyftsubset SourceHanSansSC-Regular.otf \ --text-file=charset.txt \ --output-file=SourceHanSansSC-Regular-subset.ttf \ --layout-features='*' \ --glyph-names \ --symbol-cmap \ --legacy-cmap

我在实际项目中的做法是:维护一份charset.txt,里面放上项目里出现过的全部中文、英文、数字和常用标点。每次需求有新增文案,就往这个文件里补几行,重新跑一遍子集化命令。这样字体文件体积能从十几MB降到几百KB,页面加载速度提升立竿见影。

如果你不确定项目会用到哪些字符,保守一点的做法是:把GB2312常用汉字表全部放进去,大概覆盖6700多个汉字,体积依然可控。实测下来,思源黑体子集化到GB2312级别,TTF体积大概在2MB-3MB,如果有gzip压缩,传输体积还会再低一些。

2.3 官方脚本生成vfs_fonts.js

pdfmake的项目仓库里提供了一个build-vfs.js脚本,作用是把字体文件转换成Base64字符串,输出成vfs_fonts.js。vfs_fonts.js本质是一个对象,键是文件名,值是文件的Base64内容。

用Node运行构建脚本:

node build-vfs.js SourceHanSansSC-Regular-subset.ttf

执行完成后会生成一份vfs_fonts.js,里面大致长这样:

this.pdfMake = this.pdfMake || {}; this.pdfMake.vfs = { "SourceHanSansSC-Regular-subset.ttf": "AAEAAAARAQAAAAMAIAA...(一大段Base64字符串)" };

这个文件就是我们后续要引入到项目里的核心产物。如果只需要一个常规字重的字体,生成一个文件就够了;如果有粗体、斜体需求,就需要分别准备对应字重文件,然后再统一生成。

注意一个关键细节:vfs_fonts.js里挂载的对象是pdfMake.vfs,这个路径在后面代码集成时很重要。很多人就是栽在这——字体文件生成了,代码也引了,但引入路径不对,导致运行时pdfmake找不到字体文件。

3. 把字体挂进pdfmake:两种常见注入路径

3.1 传统vfs_fonts.js全局注入

vfs_fonts.js生成之后,最直接的做法是在HTML或者打包入口里引入它。引入后,pdfMake.vfs上就有字体文件了,此时还需要把字体在pdfmake里注册成“可用的字体名称”。

注册字体通过pdfMake.fonts完成:

import pdfMake from "pdfmake/build/pdfmake"; import pdfFonts from "./vfs_fonts"; pdfMake.vfs = pdfFonts.vfs; // 或者直接引入文件后自动挂载 pdfMake.fonts = { SourceHanSans: { normal: "SourceHanSansSC-Regular-subset.ttf", bold: "SourceHanSansSC-Regular-subset.ttf", italics: "SourceHanSansSC-Regular-subset.ttf", bolditalics: "SourceHanSansSC-Regular-subset.ttf" } };

这里我把bold、italics都指向了同一个Regular文件,因为子集化的字体只保留了一个字重。如果业务上确实需要粗体效果,可以在子集化阶段把Bold字重也处理一份,然后在这里分别指定:

pdfMake.fonts = { SourceHanSans: { normal: "SourceHanSansSC-Regular-subset.ttf", bold: "SourceHanSansSC-Bold-subset.ttf" } };

完成注册后,只能说明pdfmake“认识”这个字体了,要让文档真的用上它,必须在docDefinition里声明。

3.2 新版URL按需加载字体

pdfmake新版还支持通过URL方式动态加载字体文件,这种方式的好处是不用把所有字体Base64都塞进主包,而是把字体文件放在静态资源目录,在初始化时按需加载。

pdfMake.fonts = { SourceHanSans: { normal: { url: "/fonts/SourceHanSansSC-Regular-subset.ttf" } } };

这种方式使用起来更轻量,但依赖运行环境能正常发出网络请求,且加载字体需要异步完成,所以初始化逻辑要注意时序。老项目里如果涉及复杂的初始化链,我一般建议还是用vfs注入方式,兼容性最稳定;新项目且字体资源较多时,URL加载会让包体积更友好。两种方式各有适用场景,看项目具体情况选。

3.3 文档定义与defaultStyle的绑定

字体注册完之后,还需在docDefinition里指定默认字体:

const docDefinition = { defaultStyle: { font: "SourceHanSans" }, content: [ { text: "这是一段中文字体测试内容" }, { text: "这是英文和数字1234567890" } ] }; pdfMake.createPdf(docDefinition).download("输出文件.pdf");

这一步很容易被忽略。注册了字体但不在defaultStyle里声明,pdfmake仍然会用默认的Roboto去渲染所有文本,中文照样乱码。

有个细节要注意:这个font的值必须和pdfMake.fonts对象里的key完全一致,差一个字母都不行。我在项目里就见过一次把key写的是SourceHanSans,defaultStyle里写成了sourceHanSans,结果排查了半天。

4. 模块化工程里的打包落地与体积控制

4.1 webpack场景中引入字体的正确姿势

传统webpack工程里,引入vfs_fonts.js最直接的方式是把它当作普通模块引入:

import pdfMake from "pdfmake/build/pdfmake"; import pdfFonts from "pdfmake/build/vfs_fonts"; pdfMake.addVirtualFileSystem(pdfFonts);

如果你用的是较老版本的pdfmake,addVirtualFileSystem方法可能不存在,那就用直接赋值的方式:

pdfMake.vfs = pdfFonts.vfs;

这两种方式我都实测过。新版本推荐用addVirtualFileSystem,它内部会做合并处理,不会把已有的vfs覆盖掉。老版本直接赋值也够用,前提是你只有一个vfs来源。

webpack打包时还有一个容易踩的坑:vfs_fonts.js体积很大,webpack默认的asset大小限制会把这个文件当成可加载资源,可能需要额外配置。如果构建时报警告或者报错,检查一下webpack对超大JS文件的处理策略,必要时用splitChunks把它单独分包。

4.2 vite场景的动态加载与分包

vite工程的情况和webpack不太一样。vite构建时对单文件体积没有webpack那种强约束,但浏览器对单chunk下载仍然有性能瓶颈。vfs_fonts.js动辄好几MB,直接作为同步模块打进入口,首屏加载会明显变慢。

更合理的做法是动态加载。pdfmake本身支持在运行时按需注册字体,所以我们可以把字体注册逻辑单独抽成一个模块,在真正需要导出PDF时才加载:

async function createPdfWithChineseFont() { const pdfMake = (await import("pdfmake/build/pdfmake")).default; const pdfFonts = await import("./vfs_fonts"); pdfMake.addVirtualFileSystem(pdfFonts); const docDefinition = { ... }; return pdfMake.createPdf(docDefinition); }

这样vfs_fonts.js会单独分包,用户不点导出按钮就不会下载这个文件,首屏性能几乎不受影响。这个思路同样适用于webpack的dynamic import。

4.3 从“能用”到“好用”的加载优化

字体文件无论怎么精简,对前端项目来说仍然是额外负担。有几种优化手段可以叠加使用:

第一,子集化一定要做。之前说过,把中文字体裁剪到GB2312级别,体积能压到原来的十分之一以下,这点对整体加载体验提升最直接。

第二,HTTP压缩要开。虽然Base64字符串的压缩率不如原始二进制高,但gzip对Base64仍然有30%-40%的压缩效果,服务器开了gzip或Brotli,传输体积会进一步下降。

第三,缓存策略要定好。vfs_fonts.js如果不做子集化,内容几乎不变,可以给这个文件设置长缓存时间;如果子集化后仍会随文案更新,那就需要合理地控制缓存策略,避免用户加载到旧版本字体。

第四,全量和子集化版本拆分。开发环境用全量字体保证不出错,生产环境用子集化字体保证性能。这个方案看起来笨,但在项目里最实用,省去了频繁维护字符集文件的麻烦。

5. 常见问题与排查技巧实录

5.1 引入字体后中文还是豆腐块

这是最高频的问题,工作里被问过很多次。明明按照流程导入了字体,代码也写了,导出PDF中文还是方块。遇到这种情况,按顺序排查:

先看vfs里有没有字体文件。在浏览器控制台执行pdfMake.vfs,展开对象,看字体文件名的key存不存在。如果不存在,说明vfs_fonts.js没有正确加载,或者加载后没有被赋到pdfMake.vfs上。

再看fonts注册是否成功。执行pdfMake.fonts,看字体名是否注册,每个样式对应的是不是vfs里存在的文件名。

最后看docDefinition里defaultStyle.font的值,和pdfMake.fonts里的key是否一字不差。

三步排查完,90%的问题都能定位。剩下那10%,多半是webpack或vite的模块作用域问题——vfs_fonts.js在全局挂载的pdfMake和import进来的pdfMake不是同一个实例。这种情况直接用pdfMake.addVirtualFileSystem(pdfFonts)或显式赋值就能解决。

5.2 部分汉字缺失与粗体失效

很多字都能正常显示,但个别字变成空方块,这是子集化时字符集没覆盖全的症状。渲染PDF的那台环境里没这个字形,就会缺字。解决方法是把缺失的字符补进charset.txt,重新执行一遍子集化。

粗体失效则是另一个常见问题。如果注册字体时bold指向的字体文件不存在,或者指向的文件实际是个Regular字重,PDF里的粗体文本看起来并不加粗,甚至渲染报错。解决办法,要么准备真正的Bold字重子集文件,要么直接在文档里不用bold样式,用其他方式做视觉区分。

5.3 表格:问题症状、原因与对策速查

症状可能原因解决方案
中文全部变方块/问号vfs未注入或未设置defaultStyle.font检查pdfMake.vfs和defaultStyle.font
中文能显示但个别字缺失子集化字符集覆盖不全补充charset.txt重新生成字体
英文字体正常中文字体异常文档中部分样式指定了Roboto在style中显式指定中文字体名
bold无效或警告bold指向了不存在的字重文件准备真正的Bold字体或避免使用bold
生成PDF很慢/内存占用高字体体积大且每次生成都嵌入子集化精简字体,必要时单例复用pdfMake
页面加载卡顿vfs_fonts.js同步加载改为动态import按需加载

这六类问题覆盖了我见过的绝大部分pdfmake中文字体乱码场景。看到这里,再遇到中文乱码,基本可以按图索骥解决问题。

最后分享一个我踩过多次坑后总结出来的习惯:无论项目最终是走webpack还是vite,我都会把字体获取、子集化、vfs生成、注册、测试这几步固化成一个npm脚本,放进项目根目录。这样团队成员接手时,不需要重新看文档,跑一次命令就能完成字体体系重建。文档写一百遍,不如把流程自动化落地,这个思路在做其他前端基建时同样适用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询