ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

前端导出中文PDF乱码?pdfmake字体嵌入与分页实战

前端导出中文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 }, // 页面边距,单位是 pt,1pt ≈ 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 或 OTF,pdfmake 不接受 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 pdfmake@0.2 --save # vfs_fonts 通常随 pdfmake 一起提供,单独引入时确认路径 npm install pdfmake@0.2 --save

如果项目用的是 Vite 或 Webpack 5,pdfmake/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 用阅读器打开,检查字体嵌入和分页。从那以后我每次改报表配置,都强制走一遍这两个检查,再没出现过上线后用户反馈乱码的情况。希望帮到你。

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

返回列表