ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

pdfmake 导出中文 PDF 乱码解决:字体注册与 vfs 实战

pdfmake 导出中文 PDF 乱码解决:字体注册与 vfs 实战

简介:这份资源面向需要在Web前端或Node环境中导出中文PDF的开发者,尤其是曾因字体缺失导致中文乱码而受阻的初中级工程师。它基于pdfmake给出可直接运行的示例,通过内置中文字体方案解决导出内容显示为方块或问号的问题,适用于报表导出、订单打印、数据归档等场景。压缩包共4个文件,约2.36MB,以2个JavaScript文件为主,分别承担pdfmake核心库与中文字体映射的职责,另有1个HTML页面作为调用入口,整体结构精简,解压后即可在浏览器中验证导出效果。目前已有1313人学习下载,说明该方案在实际项目中具备一定参考价值。读者可借此快速理解pdfmake的字体注册与文档定义方式,掌握中文无乱码导出的关键配置,并以此为模板替换成自己的业务数据,减少从零排查字体问题的时间成本。

1. 前端导出中文 PDF 的乱码困局:pdfmake 到底能不能扛住

做过管理后台的同行大概率都遇到过这个需求:表格数据要一键导出成 PDF,还得带公司抬头、分页页码、中文字段名。用浏览器原生打印吧,样式不可控;用后端生成吧,又得来回传文件。于是很多人把目光投向 pdfmake——一个纯前端的 PDF 生成库,声明式文档定义,跑在浏览器里,不依赖服务端。

但真正上手就会发现一个绕不开的坎:英文数字一切正常,一遇到中文就翻车。要么整段空白,要么满屏方块,要么控制台报一个看不懂的字体错误。这不是 pdfmake 的 bug,而是它默认只内置了有限的西文字体,中文字形根本不在里面。这篇笔记就围绕「pdfmake 实现导出中文 PDF 无乱码文件」这条主线,把字体注册、vfs 虚拟文件系统、打包体积、分页表格这些实操细节讲透,让新手能照着跑通,熟手能看到参数边界和踩坑点。

2. 乱码的根因与字体选型:为什么内置字体救不了中文

2.1 从 PDF 字体嵌入机制说起

要理解乱码,得先知道 PDF 是怎么显示文字的。PDF 文件本身不存储「字形图片」,它存储的是「字符编码 + 字体引用」。渲染器拿到编码后,去对应字体里查字形轮廓,再画到页面上。如果字体里没有这个字符的字形,渲染器就只能画空白或者一个方块占位符。

pdfmake 默认使用的是一套标准 PDF 字体(类似 Helvetica 这类),这套字体只覆盖拉丁字母和少量符号。当你把「订单编号」这样的中文塞进文档定义,pdfmake 在生成 PDF 时找不到对应字形,结果就是乱码或空白。所以解决思路很明确:把一套包含中文字形的字体文件,嵌入到生成的 PDF 里。

这里有个关键点容易被忽略:嵌入字体不是「引用系统字体」,而是把字体文件的子集真正写进 PDF 二进制流。这意味着字体文件本身要能被 pdfmake 读取,且格式要兼容。

2.2 TTF 还是 OTF:字体格式的取舍

常见的中文字体格式有 TTF 和 OTF 两种。pdfmake 底层依赖的 PDF 生成引擎对 TTF 的支持最稳定,OTF 在部分版本上会出现解析异常。我一般优先选 TTF,尤其是那些字形完整、授权清晰的开源字体。

选字体时还要看三个维度:

维度说明建议
字形覆盖是否包含常用汉字、标点、数字选覆盖 GB2312 以上的字体
文件体积直接影响打包和加载时间完整中文字体动辄 10MB+,需做子集化
授权协议商用是否允许选开源可商用协议

一个完整的简体中文字体,TTF 文件通常在 8MB 到 20MB 之间。如果直接把这个文件打进前端 bundle,首屏加载会非常难看。所以字体子集化是绕不开的一步——只保留项目里实际用到的字符,体积能压到几百 KB 甚至几十 KB。

2.3 字体子集化的基本思路

子集化的逻辑是:扫描项目源码和可能的数据源,提取所有出现过的中文字符,生成一个字符集,然后用工具从这个字符集里裁剪字体。这样生成的字体文件只包含用到的字形,体积大幅下降。

但这里有个坑:如果导出内容是动态的,比如用户输入的名字、数据库里的地址,字符集就无法提前穷举。这种情况下要么保留完整字体,要么在运行时做动态子集化(复杂度高)。多数后台系统的导出字段是固定的表头和有限的枚举值,静态子集化就够用。

3. 把中文字体接进 pdfmake:从注册到出稿的完整链路

3.1 安装依赖与准备字体文件

先装 pdfmake。用 npm 或 yarn 都行:

npm install pdfmake --save

然后把准备好的中文 TTF 字体放到项目里,比如src/assets/fonts/目录下。假设字体文件叫SourceHanSans.ttf(这里只是举例,实际用你选的开源字体)。

pdfmake 在浏览器端需要一个虚拟文件系统(vfs)来存放字体数据。它提供了一个pdfFonts对象,我们可以把字体文件转成 base64 字符串塞进去。手动转 base64 很麻烦,通常用构建工具处理。

3.2 用构建脚本把 TTF 转成 pdfmake 可用的 vfs

最省事的做法是写一个 Node 脚本,读取 TTF 文件,生成一个 JS 模块导出 base64。下面是一个可复用的脚本:

// scripts/build-font-vfs.js const fs = require('fs'); const path = require('path'); // 字体文件路径,按实际项目调整 const fontPath = path.resolve(__dirname, '../src/assets/fonts/SourceHanSans.ttf'); // 输出路径 const outPath = path.resolve(__dirname, '../src/assets/fonts/vfs_fonts.js'); const fontBuffer = fs.readFileSync(fontPath); const base64 = fontBuffer.toString('base64'); // pdfmake 的 vfs 结构是一个对象,key 是文件名,value 是 base64 const vfsContent = `module.exports = { "SourceHanSans.ttf": "${base64}" }; `; fs.writeFileSync(outPath, vfsContent, 'utf8'); console.log('vfs 字体文件已生成,大小:', (base64.length / 1024).toFixed(1), 'KB');

这段脚本做了三件事:读取 TTF 二进制、转成 base64、按 pdfmake 要求的对象结构写出 JS 模块。生成的vfs_fonts.js可以直接被前端代码 import。

参数说明:fontPath指向你的字体文件,outPath是生成的目标文件。base64 会让体积膨胀约 33%,所以子集化之后再做这一步,否则生成的文件会很大。

3.3 在业务代码里注册字体并生成文档

拿到 vfs 之后,注册和使用就顺理成章了:

// pdfExport.js import pdfMake from 'pdfmake/build/pdfmake'; import pdfFonts from './assets/fonts/vfs_fonts'; // 把自定义字体合并进 pdfmake 的字体表 pdfMake.vfs = { ...pdfMake.vfs, ...pdfFonts }; // 注册字体族,指定 normal/bold/italics 各自对应的字体文件 pdfMake.fonts = { SourceHanSans: { normal: 'SourceHanSans.ttf', bold: 'SourceHanSans.ttf', italics: 'SourceHanSans.ttf', bolditalics: 'SourceHanSans.ttf' } }; // 文档定义 const docDefinition = { defaultStyle: { font: 'SourceHanSans' // 关键:全局默认字体指向自定义字体 }, content: [ { text: '订单导出报表', fontSize: 18, bold: true, margin: [0, 0, 0, 12] }, { text: '客户名称:某科技有限公司', fontSize: 12 }, { text: '导出时间:2025-01-01', fontSize: 12 } ] }; // 触发下载 pdfMake.createPdf(docDefinition).download('报表.pdf');

逻辑说明:pdfMake.vfs是虚拟文件系统,pdfmake 从这里读取字体二进制。pdfMake.fonts定义字体族,把 normal、bold 等字重都指向同一个 TTF 文件(如果只有一个字重,四个都填同一个即可)。defaultStyle.font必须显式指定,否则 pdfmake 会回退到默认西文字体,中文照样乱码。

参数说明:fontSize控制字号,margin是外边距数组[左, 上, 右, 下]。download方法直接触发浏览器下载,也可以用open在新窗口预览,或者getBuffer拿到二进制流做后续处理。

3.4 表格与分页:中文长文本的排版处理

实际导出场景里,表格才是重头戏。pdfmake 的表格用table类型定义:

const docDefinition = { defaultStyle: { font: 'SourceHanSans', fontSize: 10 }, content: [ { table: { headerRows: 1, // 第一行作为表头,跨页时重复 widths: ['auto', '*', 80, 100], // 列宽:自动、自适应、固定 body: [ ['序号', '商品名称', '数量', '金额'], ['1', '无线蓝牙耳机', '2', '399.00'], ['2', '机械键盘', '1', '599.00'] ] }, layout: 'lightHorizontalLines' // 内置样式,也可自定义 } ] };

headerRows: 1让表头在跨页时自动重复,这对长表格很实用。widths里'*'表示该列占据剩余空间,'auto'根据内容自适应,数字表示固定像素宽度。中文列如果内容较长,建议给固定宽度或'*',否则'auto'可能撑出页面。

分页控制可以用pageBreak:

content: [ { text: '第一部分', pageBreak: 'after' }, { text: '第二部分' } ]

pageBreak: 'after'表示该元素之后强制分页,'before'则之前分页。

4. 避坑与排查:中文 PDF 导出最常见的五类翻车

4.1 字体注册了但依然乱码

现象:代码里明明设置了defaultStyle.font,导出的 PDF 中文还是方块。

原因:最常见的是 vfs 没合并成功,或者字体文件名大小写不一致。pdfmake 在 vfs 里查找字体时是精确匹配的,SourceHanSans.ttf和sourcehansans.ttf会被当成两个不同的 key。

解决:在浏览器控制台打印pdfMake.vfs的 key 列表,确认字体文件名和pdfMake.fonts里写的完全一致。另外检查defaultStyle是否真的生效,有些局部样式会覆盖全局字体。

4.2 打包体积暴涨,首屏加载变慢

现象:引入字体后,构建产物从几百 KB 涨到十几 MB。

原因:完整中文字体没有做子集化,base64 又放大了三分之一。

解决:先做字体子集化,只保留项目用到的字符。如果导出内容动态,考虑把字体文件放到 CDN 按需加载,或者用fetch在导出前动态加载字体再注册。不要为了省事把完整字体打进主 bundle。

4.3 加粗和斜体失效

现象:设置了bold: true,但导出的中文没有加粗效果。

原因:pdfMake.fonts里 bold 指向的还是 normal 的字体文件,而该字体本身没有独立的粗体字形,pdfmake 无法合成粗体。

解决:如果字体族有独立的粗体文件,分别指向;如果没有,可以接受「伪加粗」效果有限的事实,或者换一套包含多字重的字体。中文粗体在 PDF 里通常需要独立字体文件支持,不能靠算法加粗。

4.4 特殊字符和标点显示异常

现象:中文正常了,但某些标点、生僻字、emoji 还是乱码。

原因:子集化时字符集没覆盖到这些字符,或者字体本身就不包含这些字形。

解决:检查子集化的字符提取逻辑,把标点符号、数字、英文字母都纳入。生僻字和 emoji 需要字体本身支持,选字体时留意覆盖范围。emoji 通常需要彩色字体,pdfmake 支持有限,建议避免在 PDF 里用 emoji。

4.5 服务端渲染时字体路径找不到

现象:在 Node 环境用 pdfmake 生成 PDF,报字体文件读取失败。

原因:Node 端的 pdfmake 用法和浏览器端不同,vfs 的加载方式有差异。

解决:Node 端可以直接用fs.readFileSync读取字体文件,构造 vfs 对象。或者用pdfmake/build/pdfmake的 Node 版本,配合PdfPrinter。关键是确认字体文件的绝对路径正确,且构建工具没有把字体文件排除掉。

5. 进阶技巧:动态子集化与导出质量验证

5.1 运行时动态子集化的可行性

前面提到静态子集化无法覆盖动态内容。如果导出字段确实不可预知,一个折中方案是:保留一份完整字体放在服务端或 CDN,导出前根据实际内容提取字符集,用字体处理库在运行时裁剪。这个方案复杂度较高,适合对体积和灵活性都有要求的场景。

更简单的做法是:把完整字体放在 CDN,用fetch拿到 ArrayBuffer 后转 base64 注册进 vfs。这样主 bundle 不受影响,代价是首次导出需要等待字体加载。可以加一个 loading 状态提示用户。

5.2 导出质量的三个验证点

生成 PDF 后,别急着交付,先做三个检查:

第一,用不同阅读器打开。浏览器内置阅读器、桌面端阅读器、移动端阅读器对字体嵌入的兼容性有差异,确保主流阅读器都能正常显示。

第二,检查文本是否可选中、可搜索。如果字体嵌入方式不对,文字可能变成轮廓图形,无法选中。这对需要复制内容的用户很重要。

第三,检查分页和表格边框。长表格跨页时表头是否重复、边框是否断裂、页码是否正确,这些细节决定导出文件的专业度。

5.3 一个容易被忽略的参数:pdfmake 的版本差异

pdfmake 不同版本在字体处理上有细微差异。较新的版本对 vfs 的合并方式、字体族的定义结构可能有调整。我一般会在项目里锁定一个稳定版本,升级前先在测试环境验证中文导出是否正常。血泪经验是:不要在生产环境直接升级 pdfmake 大版本,字体相关的 breaking change 往往不会写在显眼的更新日志里。

另外,如果项目同时用了多个 PDF 相关库,注意它们对全局字体注册的干扰。pdfmake 的pdfMake.fonts是全局对象,多个实例互相覆盖的情况并不少见。封装一个独立的导出模块,把字体注册收敛到一处,能省掉很多排查时间。

我自己现在的习惯是:任何涉及中文 PDF 导出的项目,第一步先跑一个最小 demo,只输出一行中文,确认字体链路通了,再往上叠表格和样式。这个习惯帮我省下了至少两次在复杂业务代码里大海捞针的时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进