
简介FPDF是PHP中生成PDF文档的经典开源类库默认并不支持中文字体嵌入输出。这份资源面向需要在PHP项目中生成中文PDF的开发者整合了FPDF 1.81核心代码、中文输出方案chinese.php、字体格式转换程序ttf2pt1以及已转换好的黑体字体文件能够直接解决中文乱码、字形缺失等常见问题。资源包共47个文件整体约10.4MB包含20个PHP脚本、20个Map字体映射文件并提供AFM、TTF、Z等字体辅助格式覆盖字符映射定义、字体解析、格式转换与示例应用等环节。配套example.php可直接运行输出中文PDF方便初学者快速验证同时为有一定经验的PHP开发者保留了完整的字体配置工具链与参考实现。目前已有419人学习/下载适合需要快速实现中英文PDF导出功能的Web开发者。1. FPDF 输出中文资料不少为什么照抄还是会乱码FPDF 在 PHP 圈子里是又老又稳的 PDF 生成方案这类资料包里通常带着 fpdf.php、chinese.php 扩展和几个中文字体文件目标就一个让 PDF 能打出中文。但很多照着资料抄的人第一次跑出来的不是满屏方块就是复制出来一串乱码。原因不复杂FPDF 原生只认识单字节编码Latin-1而中文天生是多字节光有字体文件不转编码等于把汉字硬塞进一个不认字的文本流。这篇文章把背后的编码模型、三条落地路线、字体嵌入与子集化的参数、以及最容易翻车的五个坑一次讲清适合正在给合同、发票、报表系统补中文导出的人也适合打算从老方案迁到新方案但怕踩坑的接手者。2. FPDF 中文输出的底层约束编码、字体与选型FPDF 为什么原生不支持中文这个说法其实不精确。它不是不支持而是它的字符串模型压根没有给多字节字符留位置。你只有先看清楚这个模型才知道后面那些参数为什么要那样设。2.1 FPDF 的字符串模型单字节文本流与宽度表FPDF 在生成 PDF 时是把 PHP 字符串当作字节数组逐字节处理的。它从字体定义文件里读一个字符宽度表cw 表用strlen和substr这种单字节函数去切字符串每切出一个字节就查一次宽度然后按这个宽度把字符写进 PDF 的文本流。这套机制对西文字体完全够用因为拉丁字符的宽度表只有 256 个条目一个字节正好对应一个字符。中文不一样。常用汉字就有三千多个加上标点、全角符号、生僻字轻松破万。FPDF 的宽度表只有 256 个位置它根本没有能力表达这个字节是某个汉字的第一个字节那个字节是第二个字节。所以你直接往 FPDF 里塞 UTF-8 字符串它会把一个汉字的三个字节当成三个独立的字符去查宽度查不到就按默认宽度算结果就是文本挤成一团或者直接乱掉。2.2 中文要在 PDF 里正常显示三个条件缺一不可绕过 FPDF 的字符模型之后你还要面对另一层问题PDF 规范本身对字体的要求。一个能正常显示、能复制、能搜索的中文 PDF至少同时满足三个条件第一写入 PDF 文本流的字节序列和字体内部的字符编码一致。你用 GBK 编码的字符串配一个按 GBK 解析的字体这就算对上。你用 UTF-8 字符串配一个只做 GB2312 映射的老字体那字节对不上显示必然错位。第二字形数据真的在 PDF 里。要么把字体作为子集嵌入 PDF 文件要么依赖阅读器本地装有对应字体。前者可靠后者是碰运气。很多在我的电脑上打开正常发给客户就变方块的翻车现场就是寄希望于阅读器本地字体。第三PDF 里有 ToUnicode 映射表。这张表的作用是把 PDF 内部的字节序列翻译回 Unicode 文本复制、搜索、提取文本全靠它。没有这张表字可能显示得完全正常但复制出来全是乱码。这三个条件恰好就是后面三条路线的主要分水岭。2.3 三条路线怎么选映射扩展、FPDF 兼容库、HTML 框架老资料包里的做法、现代 FPDF 兼容库、以及基于 FPDF 内核的 HTML 转 PDF 框架这三条路线对应上面三个条件的完成度完全不同。路线代表方案编码处理字形嵌入ToUnicode体积控制上手成本A老扩展fpdf chinese.phpUTF-8 转 GBK/Big5不嵌入依赖映射字体无小最低BFPDF 兼容库TCPDF原生 UTF-8可嵌入支持子集化有可控低CHTML 框架mPDF原生 UTF-8嵌入 子集化有可控中我的建议很直接新项目直接走路线 BTCPDF 的 API 跟 FPDF 几乎一一对应迁移成本比很多人想象的低。路线 A 只适合两种场景一是你手头资料包是别人几年前的代码改起来牵一发动全身二是内部系统临时导出对可复制性无要求。路线 C 适合内容里有大段富文本、表格、CSS 排版的场景它包装的层次更高但 PHP 环境要求和内存占用也更大。3. 老资料包里的主流做法FPDF chinese.php 映射字体先讲资料包里最常出现的老方案。它的思路从今天看很取巧不解决多字节编码而是准备一个映射字体把汉字逐个塞进单字节的码位里。你在 PHP 端把 UTF-8 转成 GBK 字节流再用这个扩展类输出PDF 里写的是单字节序列阅读器用那个映射字体去渲染出汉字。3.1 资料目录结构与引入顺序这类资料解压之后目录结构通常长得差不多核心就是三样FPDF 官方类库、中文扩展文件、映射字体文件。我第一次用的时候没注意引入顺序直接报Class PDF_Chinese not found因为chinese.php里的扩展类继承自FPDF父类没加载子类自然起不来。fpdf/ ├── fpdf.php # FPDF 主类 ├── chinese.php # PDF_Chinese 扩展类 ├── font/ # 字体目录 │ ├── chinese.ttf # 映射字体每个单字节码位对应一个中文字形 │ └── ... # 其他相关字体文件 ├── test.php # 资料自带的示例脚本 └── out/ # 输出目录引入顺序是最容易被忽略的坑。chinese.php必须在fpdf.php之后加载因为PDF_Chinese类继承了FPDF类父类没被声明子类一加载就报致命错误。我习惯用require_once而不是require防止同一个脚本在二次 require 时重复定义类的报错。3.2 最小可跑代码UTF-8 转 GBK 后再输出老方案的核心就是转码。现代 PHP 项目里字符串基本都是 UTF-8但chinese.php里的映射字体只认 GBK 字节序列所以你必须在输出之前用iconv转一次编码。?php require_once __DIR__ . /fpdf/fpdf.php; require_once __DIR__ . /fpdf/chinese.php; $pdf new PDF_Chinese(); $pdf-AddGBFont(); // 注册简体中文映射字体对应字体名 GB $pdf-AddPage(); $text 合同编号ABC-2024-001金额1000 元整; // 老方案的硬性要求必须转成 GBK直接用 UTF-8 会乱码 $text iconv(UTF-8, GBK//IGNORE, $text); if ($text false) { throw new RuntimeException(转码失败检查源字符串编码); } $pdf-SetFont(GB, , 14); // 三个参数字体名、样式、字号 $pdf-Cell(0, 12, $text, 0, 1, L); // 宽、高、内容、边框、换行、对齐 $pdf-Output(F, __DIR__ . /out/demo_gbk.pdf);这段代码里有几个参数值得记住。AddGBFont()注册的字体名是GB所以SetFont的第一个参数必须写GB不能写你自己随便起的名字。Cell的第一个参数0表示宽度撑满整行第二个12是单元格高度不是字号第三个参数是文本内容第四个0表示不画边框第五个1表示输出后换行第六个L表示左对齐。iconv里的//IGNORE是让转码遇到无法映射的字符时直接丢弃而不是报错中断如果你希望非法字符被替换成问号可以用//TRANSLIT。3.3 繁体、多页与连续输出的三处配置老方案做繁体输出、多页输出时还有三个容易卡住的地方。繁体版要调用AddBig5Font()注册的字体名是Big5转码目标也要改成BIG-5。简体繁体不能同时设成当前字体一次输出只能选一种所以如果要在一个 PDF 里混排简繁需要手动切分字符串分段设置字体。多页输出时手册里通常会看到这样的循环$pdf new PDF_Chinese(); $pdf-AddGBFont(); $rows [第一行数据, 第二行数据, 第三行数据]; foreach ($rows as $index $row) { if ($index 0 $index % 30 0) { $pdf-AddPage(); // 手动分页后重新确认字体部分老扩展实现里页切换会重置字体状态 $pdf-SetFont(GB, , 14); } $pdf-Cell(0, 8, iconv(UTF-8, GBK//IGNORE, $row), 0, 1, L); } $pdf-Output(F, __DIR__ . /out/demo_pages.pdf);第三个坑是Cell输出中文时行高给得太小会截断文本。中文是全角字形实际渲染高度比同字号西文高Cell第二个参数建议不小于字号的 1.5 倍。比如字号14行高至少给20以上否则字体下边缘会被裁掉。3.4 老方案的四个硬边界什么时候必须换老方案能跑通但我不建议在对外交付的文档里用它。它有四个硬边界每一个都可能变成生产事故。第一字形数据没有按标准 Unicode 嵌入 PDFPDF 文件里的文本流是单字节伪编码。阅读器靠那个映射字体才能正确渲染换一个不认这个映射字体的阅读器满屏方块立刻出现。第二没有 ToUnicode 映射表PDF 里复制出来的中文全是乱码。用户从下载的合同里复制一段条款粘到邮件里全是乱字符这种体验一次就能让业务方炸毛。第三映射字体本身是资料打包者预制的字形覆盖范围有限生僻字、特殊符号可能直接缺失而且这个字体文件的授权状态往往说不清商用有风险。第四这套代码太老跟新版本 PHP 的兼容性越来越差。PHP 8.2 之后动态属性都被弃用了老FPDF类运行时挂属性的写法会刷一堆 deprecation 警告。如果只是内部工具临时用老方案能顶一阵但如果文档要发客户、要归档、要支持文本检索我建议直接跳到第 4 章的 TCPDF 方案。4. 直接可用的现代路线FPDF 的 API 平移到 TCPDF中文一次过TCPDF 是 FPDF 的同源分支API 保留度很高。AddPage、SetFont、Cell、MultiCell、Output这些方法名和参数顺序几乎照搬迁移成本比你想象的低很多。更重要的是它原生支持 UTF-8 和中文字体嵌入第 2 章讲的三个条件它默认全都满足。4.1 API 对照与迁移成本哪些方法能直接照搬老项目里用到的 FPDF 方法大多能在 TCPDF 里同名调用。把下面这几个高频方法对照看一遍你就能估出工作量操作FPDF 写法TCPDF 写法新建文档$pdf new FPDF()$pdf new TCPDF(P, mm, A4, true, UTF-8, false)加页面$pdf-AddPage()$pdf-AddPage()设字体$pdf-SetFont(helvetica, , 12)$pdf-SetFont(helvetica, , 12)输出文本$pdf-Cell(0, 10, text, 0, 1)$pdf-Cell(0, 10, text, 0, 1)保存文件$pdf-Output(F, demo.pdf)$pdf-Output(demo.pdf, F)只有一个地方要特别留意Output的参数顺序。FPDF 是先输出模式后文件名TCPDF 是先文件名后输出模式。迁移时把这两行全部检查一遍最容易在这里漏改。4.2 中文字体怎么选授权、文件格式与路径TCPDF 自带的中文字体里droidsansfallback开箱即用但更常见的做法是注册你自己的字体。字体选择上我最推荐的是思源黑体Noto Sans CJK 的中文名授权是 OFL商用没问题字形覆盖全而且同时包含拉丁字母、数字和常用符号一个字体就能解决中英文混排。字体授权覆盖范围商用建议思源黑体 / Noto Sans CJKOFL简繁中文、拉丁、符号推荐文泉驿微米黑GPL Font Exception中文常用集可用需自查范围微软雅黑、宋体专有授权系统自带商用有风险慎用文件格式上优先选单独的.otf或.ttf文件不要用.ttc字体集合文件。.ttc一个文件里打包好几个字体TCPDF 的字体解析器有的是直接从文件头读取遇到集合文件容易解析失败。思源黑词官网提供NotoSansCJKsc-Regular.otf这种单字体文件下载这个版本最省事。字体文件路径建议放在 Web 根目录之外避免被直接下载我在项目里一般放项目目录/data/fonts/。4.3 addTTFfont 注册字体从字体路径到子集化的参数拆解这是整个方案里最关键的一段代码参数多但每一个都有实际意义。?php require_once __DIR__ . /tcpdf/tcpdf.php; $pdf new TCPDF(P, mm, A4, true, UTF-8, false); $pdf-SetAutoPageBreak(true, 15); $pdf-SetMargins(20, 20, 20); $pdf-AddPage(); // 字体文件放在 web 目录之外避免被直接下载 $fontFile __DIR__ . /fonts/NotoSansCJKsc-Regular.otf; $fontName $pdf-addTTFfont( $fontFile, TrueTypeUnicode, // 按 Unicode 解析中文必需 , // 编码参数留空即可 32, // flags32表示非符号字体 , // 输出路径默认是 TCPDF 的 fonts 目录 , // 平台 ID空则自动 , // 编码 ID空则自动 false, // 不计算字符边界框省解析时间 true // subsettrue只嵌入用到的字形 ); $pdf-SetFont($fontName, , 12); $text 中文 PDF 输出测试\n; $text . 合同编号ABC-2024-001\n; $text . 金额¥1,234.50; $pdf-MultiCell(0, 8, $text, 0, L, false, 1); $pdf-Output(__DIR__ . /out/demo_tcpdf.pdf, F);addTTFfont的第八个参数false和第九个参数true要重点解释。第八个参数是是否添加字符边界框开启后每个字符会多存一份坐标数据PDF 体积变大一般场景用不到。第九个参数是子集化开关开true后 PDF 只嵌入文档里实际用到的那些字形。一个全量思源黑体 20MB 以上子集化之后常常只剩几十 KB。如果你的 TCPDF 版本对第九个参数不生效在AddPage()之前执行一次$pdf-setFontSubsetting(true);也能达到同样的效果。addTTFfont第一次调用时TCPDF 会解析字体文件在自身的 fonts 目录下生成缓存文件比如字体定义文件、字形压缩文件、字符宽度映射文件。第二次再调用同一个字体就直接复用缓存不再重新解析。所以第一次生成时会稍慢后续就正常了。4.4 MultiCell 换行与自动分页告别 Text 硬坐标老 FPDF 资料里常用Text(x, y, 内容)这种硬坐标方式写文本中文一长就出问题因为Text不会换行。TCPDF 里我基本只用MultiCell处理中文段落。// MultiCell宽度 0撑满到右边距高度 8每行行高 // 边框 0不画对齐 L左对齐填充 false不透底换行 1自动流到下一行 $pdf-MultiCell(0, 8, $text, 0, L, false, 1); // 控制自动分页的开关和距底边距离 $pdf-SetAutoPageBreak(true, 15);MultiCell的第七个参数1表示这段文本如果超过剩余页面空间自动把剩余部分移到下一页这是中文长文本输出必须的参数。对齐方式上中文段落用J两端对齐比L更接近印刷排版效果但代价是字母间会留白合同类文档我一般用L更稳。字体设置之后$pdf-GetStringWidth($text)返回的是当前字体度量下字符串的实际宽度单位是毫米你可以用它来预判文本长度决定是缩小字号还是拆分段落。5. 中文输出翻车现场FPDF 方案的常见问题与排查这一章全是能实际复现的问题。我把五年来帮别人排查 FPDF 中文输出踩过的坑整理成固定格式现象、原因、解决。你照着对号入座就行。5.1 满屏方块字形为什么没进 PDF现象PDF 能打开中文位置也在但每个汉字都显示成方块或者空白英文和数字正常。原因字形数据不在 PDF 里。老方案的映射字体在部分现代阅读器里不被正确解析TCPDF 路线下则是字体文件路径写错addTTFfont没有真正读入字体SetFont用的字体名也对应不上。解决先用命令确认问题在哪一层pdffonts demo.pdf看输出表格的emb列如果是no说明字体压根没嵌入 PDF。老方案里这就是硬伤只能换路线。TCPDF 路线下检查addTTFfont传入的路径是否存在、是否有读权限确认返回值不是空字符串。我见过太多人把字体路径写成相对路径换一台机器部署就挂了字体路径一律用__DIR__拼接绝对路径才稳。5.2 能显示但复制是乱码缺 ToUnicode 映射现象屏幕上显示的汉字完全正常但用鼠标选中复制粘出来是Ê?§ä½这种乱码搜索功能也搜不到内容。原因PDF 没有 ToUnicode 映射表。老方案的单字节伪编码从根上就无法提供标准映射。TCPDF 路线下则可能是你注册字体时第三、第七个参数传错了导致字符映射关系没写进 PDF。解决继续用pdffonts看输出这次看uni列no就是没有映射表。老方案改不了只能换第 4 章的路线。TCPDF 路线下把addTTFfont的第二个参数固定为TrueTypeUnicode这就是让 TCPDF 按 Unicode 建立字符映射的关键。改完重新生成再跑一次pdffontsuni列变成yes才算过。5.3 一个两页 PDF 变成 30MB全量字体嵌入的代价现象PDF 内容很少但文件巨大动辄二三十 MB甚至生成时内存溢出。原因全量嵌入了中文字体。思源黑体一个字体文件就有 20MB 左右没有开启子集化所有字形全部塞进 PDF哪怕你只用了十来个汉字。解决TCPDF 里开启子集化前面第 4.3 节代码里addTTFfont的第九个参数传true或者在AddPage()前执行$pdf-setFontSubsetting(true);。开启后一个两页的中文 PDF 体积通常会降到几十 KB 到一两百 KB视实际用到的字形数量而定。生成后可以用pdffonts复核体积看看是否明显下降。5.4 PHP 8 下老代码的报错动态属性与旧构造器现象本来在老 PHP 环境跑得好好的资料包代码迁到 PHP 8.2 后满屏Deprecated: Creation of dynamic property甚至Fatal error: Call to undefined method。原因老版 FPDF 类在运行时会往实例上动态挂属性PHP 8.2 开始这个行为被标记为弃用。另外老代码里常见的function FPDF()这种跟类同名的构造器写法在 PHP 8 里也不再自动被当作构造方法。解决最省事的方案是把 fpdf.php 升级到官方最新版最新版已经兼容 PHP 8。如果你必须用资料包里的旧文件可以给类的声明前加一行#[\AllowDynamicProperties]临时压制动态属性告警但这只能解一时之痛。旧构造器问题需要手动把构造函数改成__construct()或者直接换 TCPDF一劳永逸。我的血泪经验是别在老代码上修修补补迁移到 TCPDF 花的时间真没你想的那么多。5.5 半角数字被拉宽字体回退与混排策略现象中文显示正常但英文、数字、百分号、人民币符号变得特别宽一行文本里中英文比例明显失调排版很难看。原因中文字体里往往包含拉丁字形但这些拉丁字形的宽度是按全角处理的跟专门的比例字体不一样。你用一个中文字体同时渲染英文和中文数字、字母就全变宽体了。解决最直接的办法是换一个拉丁字形更规范的中文字体思源黑体在这一点的表现就比老映射字体好很多。如果你的设计稿里必须英文用 Helvetica、中文用思源黑体那就分段切换字体$pdf-SetFont(helvetica, , 12); $pdf-Write(8, Amount: ); $pdf-SetFont($fontName, , 12); $pdf-Write(8, 人民币壹仟元整);这里用Write而不是Cell因为Write不换行可以连续输出不同字体的内容。注意两个字体切换之间不要混在同一个字符串里否则宽度计算又会出问题。6. 发版前验证用 pdftotext 和 ToUnicode 检查守住中文质量中文 PDF 最迷惑人的一点是屏幕上看是对的可能实际是错的。两种错误特别难以察觉一种是复制乱码但人眼不觉一种是字体没嵌入自己电脑上有这个字体所以显示正常换台电脑就满屏方块。所以我把打开看一眼改成固定跑一遍检查命令几秒钟的事能挡住八成以上返工。6.1 渲染正确不等于文本正确双向验证验证要双向做。第一向是渲染用浏览器或专业阅读器打开确认没有方块第二向是文本抽取用pdftotext把 PDF 里的文本抽出来看。这两向对齐了才算真正通过。#!/usr/bin/env bash # check_cjk_pdf.sh —— 中文 PDF 快速体检 set -euo pipefail pdf$1 # 1. 文本抽取能抽到中文说明 ToUnicode 映射在 text$(pdftotext -enc UTF-8 $pdf -) if [ -z $text ]; then echo FAIL: pdftotext 抽不出文本或全是空白 exit 1 fi echo OK: 文本抽取正常 # 2. 字体嵌入复核人工看 emb 列和 uni 列 pdffonts $pdf如果pdftotext抽出来的是乱码或者空字符串那说明这个 PDF 在文本层面就不合格别管屏幕上多正常直接打回。如果命令行提示pdftotext不存在先安装 poppler-utils这是 Linux 和 macOS 都能用的工具包。6.2 用输出流检查 ToUnicode把验证塞进脚本如果连 poppler 都不想装还有一个纯 PHP 的土办法在生成 PDF 的时候用Output(S)拿到完整的 PDF 字符串直接搜索ToUnicode关键字。$raw $pdf-Output(S); // S 模式返回字符串不写文件 if (strpos($raw, /ToUnicode) ! false) { // 这个 PDF 带文本映射复制搜索可用 file_put_contents(__DIR__ . /out/demo.pdf, $raw); } else { // 要么是字体注册有问题要么走的是老映射方案 error_log(警告生成的 PDF 缺少 ToUnicode 映射); }老映射方案生成的 PDF 里找不到ToUnicode所以这条检查天然就是一道分界线。TCPDF 生成的 PDF 里则一定能找到因为它的字体子集化流程默认会写这一段。我现在的习惯是任何要走合同归档、要发客户的中文 PDF生成代码里都会保留这段检查失败就直接报错不给先发出去看看的机会。这套流程救过我很多次希望也能帮到你少踩一次坑。本文还有配套的精品资源点击获取