前端导出中文PDF乱码?pdfmake字体嵌入与分页实战 简介这份资源面向需要在Web前端实现PDF导出的开发者尤其是使用pdfmake时被中文乱码问题困扰、希望快速拿到可用方案的人群。资源包围绕pdfmake导出中文无乱码PDF这一核心场景提供了一套可直接运行的演示环境省去自行配置中文字体与虚拟文件系统的摸索成本。压缩包共4个文件以js脚本和html页面为主另有系统缓存文件整体约2.36MB体积轻量便于本地保存与二次修改。目前已有1313人学习下载说明该方案在实际开发中具备一定参考价值。解压后打开演示页面即可在浏览器中触发下载直观看到中文正常显示的导出效果读者可据此对照自身项目理解字体注册与文档定义的关键写法并在此基础上替换业务数据、调整表格与排版样式快速迁移到实际导出功能中。1. 前端导出中文 PDF 的坑pdfmake 这套方案能绕过去做过管理后台的都知道导出 PDF 这个需求看着简单真上手就翻车。用浏览器打印吧分页和页眉页脚根本控不住用后端生成吧前后端来回传文件、调接口一个字段对不上就得联调半天。更别提中文了——字体没嵌进去导出来满屏方块用户截图发过来问“这是什么乱码”你还得解释半天。pdfmake 这个库就是冲着这类场景来的纯前端、声明式文档定义、支持自定义字体嵌入导出的中文 PDF 不乱码分页、表格、页眉页脚都能在代码里写死。它适合谁适合那些不想为了一份 PDF 去搭一套后端渲染服务、又需要稳定输出中文文档的前端开发者。下面我按“它怎么组织文档 → 怎么把中文字体塞进去 → 怎么跑起来 → 怎么改”的顺序把这份资源拆开讲。2. 文档定义模型拆解pdfmake 是怎么把 JSON 变成 PDF 的pdfmake 的核心思路很直接你不写绘图指令你写一棵“文档定义树”库负责把它翻译成 PDF 的底层对象。理解这棵树的结构后面调样式、调分页才不会瞎试。2.1 docDefinition 的四个关键字段一份 pdfmake 文档就是一个普通对象最常用的字段是content、styles、defaultStyle、pageMargins。content是内容数组里面每个元素可以是一段文字、一个表格、一张图片也可以嵌套列columns和栈stack。styles定义命名样式defaultStyle兜底全局字体和字号。下面是一个最小可运行的定义// 最小文档定义一段中文 一个两列表格 const docDefinition { // 全局默认样式font 必须指向已注册的字体名 defaultStyle: { font: NotoSansSC, // 后面 registerFonts 时用的名字 fontSize: 11, lineHeight: 1.4 }, // 页面边距单位是 pt1pt ≈ 1/72 英寸 pageMargins: [40, 60, 40, 60], content: [ { text: 项目验收报告, style: title }, { text: 本报告由前端直接生成无需后端参与。, margin: [0, 8, 0, 16] }, { table: { // headerRows: 1 表示第一行是表头跨页时自动重复 headerRows: 1, widths: [30%, *], body: [ [字段, 值], [项目名称, 某跨平台系统], [导出时间, new Date().toLocaleString(zh-CN)] ] } } ], styles: { title: { fontSize: 18, bold: true, margin: [0, 0, 0, 12] } } };这段定义里content的顺序就是 PDF 里从上到下的顺序margin数组是[左, 上, 右, 下]这个顺序容易记反我第一次写的时候把上下写颠倒了调了半天间距。table.widths里*表示剩余宽度自动分配30%是相对页面可用宽度的百分比。headerRows是个很实用的参数表格跨页时表头会自动重复不用自己手动拆表。2.2 内容节点的嵌套规则pdfmake 的内容节点可以嵌套但嵌套有边界columns里放的是并排的列每列本身又是一个内容数组stack是垂直堆叠等价于不加任何布局的默认行为。真正需要注意的是text字段既可以是一个字符串也可以是一个数组——数组形式用来做行内混排比如一段话里某几个字加粗、变色{ text: [ 状态, { text: 已通过, bold: true, color: #1a7f37 }, 审批人某导师 ] }这种行内数组的写法在生成带状态标记的报告时特别顺手不用为了一个加粗词去拆成多个段落。但要注意行内数组里的每个元素只支持文本级样式bold、italic、color、fontSize不支持margin这类块级属性写了也不生效这是很多人调样式调不出来的原因。2.3 分页与页眉页脚的控制点分页在 pdfmake 里有两个层面自动分页和手动分页。自动分页由内容高度决定库会尽量不让一个块被切断但表格行、图片这类原子块如果放不下就整体挪到下一页。手动分页用{ text: , pageBreak: after }注意这个节点本身要占一个位置通常放在两个章节之间。页眉页脚通过header和footer函数定义函数接收当前页码和总页数// 页脚第 X 页 / 共 Y 页 footer: function (currentPage, pageCount) { return { margin: [40, 0, 40, 0], columns: [ { text: 内部资料, fontSize: 9, color: #888 }, { text: 第 ${currentPage} 页 / 共 ${pageCount} 页, alignment: right, fontSize: 9 } ] }; }这里有个细节footer返回的也是一个内容节点所以它同样受defaultStyle的字体影响。如果页脚中文变成方块说明字体注册那一步没覆盖到不是页脚本身的问题。pageCount是库在排版完成后回填的所以页脚里拿到的总页数是准确的不用担心“共 1 页”这种错误。3. 中文字体嵌入不乱码的关键就在这一步中文乱码的根因只有一个PDF 里引用的字体没有嵌入字形阅读器找不到对应字符就画方块。pdfmake 默认只带 Roboto 这类拉丁字体中文必须自己注册。这一步做对了后面基本不会再有乱码问题。3.1 字体文件从哪来、放哪常见做法是选一个开源中文字体比如思源黑体Noto Sans SC或者更轻量的子集字体。完整中文字体动辄十几 MB直接打进前端包会让首屏体积爆炸所以实际项目里一般做两件事一是把字体文件放到静态资源目录按需加载二是用字体子集化工具裁掉用不到的字形。下面假设字体文件已经放在public/fonts/下文件名是NotoSansSC-Regular.ttf。# 目录结构示意 public/ fonts/ NotoSansSC-Regular.ttf # 常规体用于正文 NotoSansSC-Bold.ttf # 粗体用于标题字体文件必须是 TTF 或 OTFpdfmake 不接受 WOFF/WOFF2这一点在选字体时就要确认别下载完发现格式不对又回头找。3.2 registerFonts 的两种写法pdfmake 注册字体有两种方式一种是把字体文件转成 base64 字符串直接内联适合字体很小或者要打包成单文件的场景另一种是传 URL让库在运行时去加载。URL 方式更清爽但要注意跨域和加载时序。下面是 URL 方式的写法import pdfMake from pdfmake/build/pdfmake; import pdfFonts from pdfmake/build/vfs_fonts; // 先挂上默认字体表否则 pdfMake 内部找不到基础字体 pdfMake.vfs pdfFonts.pdfMake.vfs; // 注册中文字体名字自己定后面 docDefinition 里引用同一个名字 pdfMake.fonts { NotoSansSC: { normal: /fonts/NotoSansSC-Regular.ttf, bold: /fonts/NotoSansSC-Bold.ttf, italics: /fonts/NotoSansSC-Regular.ttf, // 没有斜体就用常规体顶替 bolditalics: /fonts/NotoSansSC-Bold.ttf } };四个键normal、bold、italics、bolditalics必须都给缺一个的话用到对应样式时库会报字体未定义。没有真正的斜体文件时用常规体顶替是常见做法视觉上不会斜但至少不报错。pdfMake.vfs那行不能省它是 pdfmake 内置的虚拟文件系统默认字体表挂上去之后库才能正常初始化。3.3 生成与下载的完整调用链注册完字体生成 PDF 就是一行createPdf的事。但下载环节有个坑pdfMake.createPdf(docDefinition).download(文件名.pdf)在部分浏览器里对中文文件名处理不一致稳妥的做法是自己拿 blob 再走一遍a标签下载// 生成 PDF 并触发下载 pdfMake.createPdf(docDefinition).getBlob((blob) { // 用 URL.createObjectURL 生成临时地址避免中文文件名被截断 const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download 项目验收报告.pdf; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 释放内存长列表导出多次时这步不能省 URL.revokeObjectURL(url); });getBlob的回调是异步的字体加载和排版都在这个过程中完成所以不要在回调外面假设 PDF 已经生成。revokeObjectURL这行在单次导出时可有可无但如果页面上有“批量导出”按钮连续点几次不释放就会累积内存血泪经验是加上。4. 从零跑起来环境、依赖与最小可运行示例拿到一份 pdfmake 的示例代码最怕的是依赖版本对不上、字体路径 404。这一章按实际搭建顺序走一遍确保你能在本地看到一个中文正常的 PDF。4.1 依赖安装与版本注意pdfmake 的 npm 包和vfs_fonts是分开的两个都要装。版本上建议锁同一个大版本混用容易出现vfs结构不一致的问题# 安装核心库和内置字体表 npm install pdfmake0.2 --save # vfs_fonts 通常随 pdfmake 一起提供单独引入时确认路径 npm install pdfmake0.2 --save如果项目用的是 Vite 或 Webpack 5pdfmake/build/pdfmake这个路径一般能直接解析如果报模块找不到检查一下构建工具的resolve.alias有没有把pdfmake指到build目录。另一个常见问题是打包后字体 URL 变成相对路径导致 404这时候把字体放到public下、用绝对路径/fonts/xxx.ttf引用最稳。4.2 一个能直接跑的最小页面把前面的字体注册和文档定义拼起来就是一个完整的最小示例。下面这段代码可以直接放进一个 HTML 页面或者 Vue/React 组件里import pdfMake from pdfmake/build/pdfmake; import pdfFonts from pdfmake/build/vfs_fonts; pdfMake.vfs pdfFonts.pdfMake.vfs; pdfMake.fonts { NotoSansSC: { normal: /fonts/NotoSansSC-Regular.ttf, bold: /fonts/NotoSansSC-Bold.ttf, italics: /fonts/NotoSansSC-Regular.ttf, bolditalics: /fonts/NotoSansSC-Bold.ttf } }; export function exportReport() { const docDefinition { defaultStyle: { font: NotoSansSC, fontSize: 11 }, pageMargins: [40, 60, 40, 60], content: [ { text: 导出测试, fontSize: 18, bold: true }, { text: 如果这行中文显示正常说明字体注册成功。, margin: [0, 10, 0, 0] } ] }; pdfMake.createPdf(docDefinition).getBlob((blob) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download 导出测试.pdf; a.click(); URL.revokeObjectURL(url); }); }跑起来之后先看两件事一是 PDF 里中文是否正常二是控制台有没有字体加载失败的 404。如果中文是方块但控制台没报错多半是defaultStyle.font的名字和pdfMake.fonts的键对不上大小写敏感NotoSansSC和notosanssc是两个东西。4.3 验证导出结果的三个检查点导出成功不等于导出正确。我一般按三个点验收第一打开 PDF 看中文和标点是否完整特别是全角逗号、书名号这类容易缺字形的符号第二翻到第二页看页眉页脚是否正常重复表格表头有没有跟着走第三用 PDF 阅读器的“文档属性”看字体是否显示为嵌入状态。第三点最容易被忽略但它是判断“换台电脑打开会不会乱码”的硬指标——字体嵌入了文件到哪台机器上显示都一样。5. 避坑与排查中文 PDF 导出最常见的五类问题这一章是我在实际项目里踩过的坑按“现象 → 原因 → 解决”整理遇到问题先对照这里查一遍能省不少时间。5.1 中文全是方块控制台无报错现象PDF 能生成但所有中文显示为方块或空白。原因defaultStyle.font引用的字体名没有在pdfMake.fonts里注册或者注册了但四个样式键没给全。解决先确认pdfMake.fonts的键名和defaultStyle.font完全一致再确认normal指向的字体文件路径在浏览器里能直接访问。可以在注册后打印一下pdfMake.fonts看结构对不对。5.2 字体文件 404但本地开发正常现象本地npm run dev导出正常打包部署后中文变方块。原因字体文件放在src/assets下被构建工具处理成了带哈希的路径而代码里写的是固定路径。解决把字体移到public/fonts/下用绝对路径引用构建工具不会改写public下的文件路径。这个坑我翻过两次后来养成习惯凡是运行时按 URL 加载的静态资源一律放public。5.3 表格跨页后表头消失现象长表格翻到第二页表头没了读者不知道每列是什么。原因table定义里漏了headerRows。解决给table加上headerRows: 1如果表头有两行就写2。注意这个值要和body里实际表头行数一致写多了会把数据行当表头重复。5.4 导出大文档时页面卡死现象几百行表格导出时浏览器卡住几秒甚至无响应。原因pdfmake 在主线程做排版内容量大了就是会阻塞。解决一是控制单次导出量分批生成再合并二是把生成逻辑放到 Web Worker 里主线程只负责接收 blob 和触发下载。常见做法是超过两百行的报表就走 Worker用户体验会好很多。5.5 中文文件名下载后变成乱码现象download(报告.pdf)下载下来文件名变成一串百分号编码。原因不同浏览器对download属性的中文处理不一致。解决不用库自带的download方法改用getBlob拿 blob 后自己创建a标签设置download现代浏览器基本都能正确处理中文文件名。6. 二次开发把导出逻辑抽成可配置的报表模块示例代码跑通之后真正要落地的是把它变成项目里可复用的模块。我的做法是把“文档定义”和“生成下载”拆开前者是一个纯函数输入业务数据、输出docDefinition后者是一个通用工具只负责字体注册和 blob 下载。这样换一份报表只需要写一个新的定义函数下载逻辑一行不用改。具体改造时我会先定义一个报表配置的接口把标题、列定义、数据源、页脚文案都参数化// 通用报表生成器配置驱动避免每份报表复制粘贴 function buildReport(config) { return { defaultStyle: { font: NotoSansSC, fontSize: 10 }, pageMargins: [36, 54, 36, 54], content: [ { text: config.title, fontSize: 16, bold: true, margin: [0, 0, 0, 10] }, { table: { headerRows: 1, widths: config.columns.map(() *), // 等宽分配也可传百分比 body: [ config.columns.map(c ({ text: c.label, bold: true })), ...config.rows.map(row config.columns.map(c String(row[c.key] ?? ))) ] } } ], footer: (page, total) ({ margin: [36, 0, 36, 0], text: ${config.footerText || } 第 ${page} 页 / 共 ${total} 页, alignment: right, fontSize: 8 }) }; }这段代码的关键点是widths用map生成等宽数组body第一行是表头、后面用展开运算符拼数据行?? 兜住空值避免undefined被打印成字符串。改造完之后新增一份报表就是传一个配置对象的事不用再碰字体和下载逻辑。验证改造是否成功我一般做两件事一是拿一份含空字段、超长文本、跨页表格的数据跑一遍看有没有崩二是把生成的 PDF 用阅读器打开检查字体嵌入和分页。从那以后我每次改报表配置都强制走一遍这两个检查再没出现过上线后用户反馈乱码的情况。希望帮到你。本文还有配套的精品资源点击获取