首页 / 资讯中心 / 文章详情

iTextPDF 5.5.10 PdfStamper 中文不显示?字体嵌入原理与实战修复

iTextPDF 5.5.10 PdfStamper 中文不显示?字体嵌入原理与实战修复 ★ FEATURED ARTICLE
1. 问题背景与核心症结拆解1.1 为什么偏偏是 PdfStamper 出问题做过 PDF 生成的朋友大概率都碰过这个场景用 iTextPDF 5.5.10 往一份现成的 PDF 模板里填数据结果英文数字一切正常中文一填进去就凭空消失页面上干干净净连个方块乱码都不给你留。这个现象在 5.x 版本里特别典型而且几乎都集中在PdfStamper这条操作路径上。先把结论摆出来中文不显示根子不在 PdfStamper 本身而在于你往它里面塞的字体对象没有真正嵌入中文字形。PdfStamper 的职责是在已有 PDF 上做增量修改它拿到你给的Font对象后会把这个字体写进 PDF 的字体资源字典里。如果你给的是一个标准 14 字体比如BaseFont.HELVETICA那这个字体压根就没有中文字形PDF 阅读器渲染时找不到对应字符自然就吞掉了。很多人第一次遇到会怀疑是编码问题于是拼命折腾UnicodeFont、encoding参数结果发现还是不行。这里要澄清一个常见误区iText 5.x 里中文显示失败90% 是字体嵌入问题10% 才是编码问题。编码决定了字节怎么映射到字符字体决定了字符有没有对应的字形可以画出来。两者缺一不可但绝大多数人卡在后者。1.2 PdfStamper 与 PdfWriter 的本质差异要理解这个问题得先搞清楚PdfStamper和PdfWriter在字体处理上的区别。PdfWriter是从零创建 PDF你调用document.add()时iText 有完整的上下文去处理字体子集化、嵌入、编码映射。而PdfStamper是打开一个已有 PDF往上面叠加内容它走的是另一套逻辑——通过getOverContent()或getUnderContent()拿到一个PdfContentByte然后你手动往这个画布上写文字。关键点在于PdfContentByte本身不管理字体嵌入它只负责把绘制指令写进内容流。字体资源需要你显式地通过BaseFont创建并嵌入。如果你用的是FontFactory.getFont()且没指定字体文件路径它很可能返回一个内置字体这个字体在 PdfStamper 场景下不会自动嵌入中文字形于是中文就消失了。我见过太多人在这里栽跟头包括我自己早期做合同模板填充的时候。当时排查了大半天最后发现就是BaseFont.createFont()的第一个参数写错了用了个系统字体名而不是字体文件路径。1.3 问题的影响范围与典型场景这个问题的波及面其实比想象中广。凡是涉及模板填充的业务几乎都会踩到电子合同/协议生成模板是设计好的 PDF需要动态填入甲乙方名称、金额、条款这些内容大量含中文。报表套打财务、物流单据的固定格式 PDF填入动态数据。证书/证明批量生成奖状、资质证书模板填入姓名、事由。发票/票据处理在标准票据 PDF 上叠加中文信息。这些场景的共同特征是模板不可改只能在既有 PDF 上叠加内容所以必须用 PdfStamper也就必然要面对中文字体嵌入这道坎。而且这类业务往往对格式要求严格字体不对、位置偏移、字符缺失都是不可接受的所以把这个问题彻底搞明白是绕不过去的基本功。2. 中文字体嵌入的底层原理与方案选型2.1 字体嵌入到底在做什么PDF 里的字体分两大类标准 14 字体和嵌入字体。标准 14 字体Helvetica、Times、Courier 等是 PDF 规范强制要求阅读器内置的所以文件里不用存字形数据体积小。但它们只覆盖拉丁字符集中文、日文、韩文这些 CJK 字符一个都没有。嵌入字体则是把字体的字形数据glyph打包进 PDF 文件阅读器渲染时直接从文件里取字形。中文字体动辄几 MB 到十几 MB全量嵌入会让 PDF 体积暴涨所以 iText 提供了**子集化subset**机制——只嵌入文档里实际用到的那些字。这就是为什么BaseFont.createFont()有个embedded参数通常传BaseFont.EMBEDDED。子集化的原理是iText 扫描你写入的所有文本收集用到的字符然后从完整字体里抽取这些字符的字形重新组织成一个精简的字体子集嵌入 PDF。这样一份含几百个汉字的合同字体部分可能只有几十 KB。理解这一点很重要因为它解释了为什么字体文件必须能被 iText 读取到——它需要解析字体文件来抽取字形。2.2 三种主流字体方案的取舍实际项目里中文字体方案主要有三种各有适用场景方案字体来源优点缺点适用场景系统字体文件服务器本地 ttf/ttc简单直接字体质量高依赖部署环境跨平台要改路径内部系统、可控环境项目内置字体打包进 jar/资源目录部署无依赖行为一致增加包体积需注意授权对外产品、多环境部署iText Asian 包iTextAsian.jar无需字体文件字体老旧样式单一快速验证、老项目我个人的经验是生产环境优先选项目内置字体。系统字体路径在不同操作系统、不同容器镜像里差异太大今天在开发机跑得好好的明天上了 Linux 容器就找不到C:/Windows/Fonts/simsun.ttc了。把字体文件放进resources目录用getResourceAsStream()读取行为最稳定。至于 iTextAsian 包它提供的是STSong-Light这类字体通过UniGB-UCS2-H编码使用。优点是省事缺点是字体样式很有限而且在高版本 iText 里逐渐被边缘化。如果只是做个内部工具快速验证可以用正式产品还是老老实实嵌字体文件。2.3 为什么必须用 IDENTITY_H 编码创建中文字体时编码参数几乎总是BaseFont.IDENTITY_H。这个选择不是随便定的背后有硬道理。PDF 的简单字体Simple Font用单字节编码最多表示 256 个字符根本装不下几千个汉字。所以要显示中文必须用复合字体Composite FontType 0它支持多字节编码。IDENTITY_H就是 iText 里对应复合字体的一种编码方式它用 CIDCharacter ID来索引字形配合UniGB-UCS2-H或UniGB-UTF16-H这样的 CMap 做字符到 CID 的映射。简单说IDENTITY_H是让 iText 用字形 ID而不是字符编码来组织字体的开关中文必须走这条路。如果你用BaseFont.WINANSI或GBK之类的编码去创建中文字体要么直接抛异常要么生成一个残缺的字体中文照样显示不出来。这里有个细节值得注意IDENTITY_H和IDENTITY_V的区别是横排和竖排。绝大多数场景用IDENTITY_HH 代表 Horizontal就够了除非你要做古籍竖排那种特殊排版。3. 完整实操从零复现并解决中文不显示3.1 环境准备与依赖确认先把环境理清楚。iTextPDF 5.5.10 的 Maven 坐标是dependency groupIdcom.itextpdf/groupId artifactIditextpdf/artifactId version5.5.10/version /dependency注意 5.5.10 是 5.x 系列里比较靠后的版本API 相对稳定。如果你用的是itextpdf这个 artifact它已经包含了核心功能。有些人会额外引入itext-asian但如果你走字体文件嵌入路线这个包不是必需的。字体文件方面我建议准备一个明确的开源中文字体比如思源黑体、文泉驿微米黑这类避免版权风险。把.ttf或.ttc文件放到项目的src/main/resources/fonts/目录下。用.ttc字体集合时要注意一个 ttc 里可能包含多个字体创建时需要指定索引比如simsun.ttc,0表示取第 0 个字体。提示.ttc文件在 iText 5.x 里读取时路径后面要跟,索引号否则可能报错或取到错误的字体。这个坑我踩过当时用simsun.ttc没加索引结果中文能显示但字形不对。3.2 错误示范中文消失的最小复现先看一段典型的踩坑代码这是很多人第一次写的版本// 错误示范中文会消失 PdfReader reader new PdfReader(template.pdf); PdfStamper stamper new PdfStamper(reader, new FileOutputStream(output.pdf)); // 用内置字体没有中文字形 Font font new Font(BaseFont.createFont(), 12); PdfContentByte canvas stamper.getOverContent(1); ColumnText.showTextAligned(canvas, Element.ALIGN_LEFT, new Phrase(甲方某某科技有限公司, font), 100, 700, 0); stamper.close(); reader.close();这段代码跑起来不报错PDF 也能生成但打开一看甲方某某科技有限公司这行字里中文全没了可能只剩个冒号。原因就是BaseFont.createFont()无参调用返回的是 Helvetica没有中文字形。还有一种更隐蔽的错误用了FontFactory.getFont(宋体, 12)。这个写法在 Windows 上有时能碰巧工作因为 FontFactory 会去系统字体目录找但一旦部署到没有该字体的环境就失效。依赖系统字体名是极不可靠的做法一定要用字体文件路径。3.3 正确实现嵌入中文字体的完整代码下面是经过验证的正确写法我把它拆成几个关键步骤import com.itextpdf.text.*; import com.itextpdf.text.pdf.*; public class ChineseStampDemo { public static void main(String[] args) throws Exception { // 1. 读取模板 PdfReader reader new PdfReader(template.pdf); PdfStamper stamper new PdfStamper(reader, new FileOutputStream(output.pdf)); // 2. 从 classpath 加载字体文件创建嵌入式中文字体 // 注意 IDENTITY_H 编码和 EMBEDDED 嵌入标志 BaseFont baseFont BaseFont.createFont( fonts/SourceHanSansCN-Regular.ttf, // 字体文件路径 BaseFont.IDENTITY_H, // 编码方式 BaseFont.EMBEDDED); // 嵌入字体 // 3. 基于 BaseFont 创建 Font 对象 Font cnFont new Font(baseFont, 12, Font.NORMAL, BaseColor.BLACK); // 4. 在指定页面叠加中文内容 PdfContentByte canvas stamper.getOverContent(1); ColumnText.showTextAligned(canvas, Element.ALIGN_LEFT, new Phrase(甲方某某科技有限公司, cnFont), 100, 700, 0); // 5. 关闭资源 stamper.close(); reader.close(); } }几个关键点必须说清楚第一字体路径的写法。BaseFont.createFont()的第一个参数如果是相对路径iText 会从 classpath 找。所以fonts/SourceHanSansCN-Regular.ttf对应的是resources/fonts/下的文件。如果你用绝对路径比如D:/fonts/xxx.ttf那部署到 Linux 就废了。统一用 classpath 相对路径是最稳的。第二BaseFont.EMBEDDED不能省。这个参数告诉 iText 把字体子集嵌入 PDF。如果传BaseFont.NOT_EMBEDDEDiText 会假设阅读器本地有这个字体但阅读器大概率没有中文照样不显示。所以中文字体必须EMBEDDED。第三Font对象要复用。如果你在一个循环里给多个字段填中文不要在循环里反复createFont那会导致字体被重复嵌入PDF 体积暴涨。正确做法是在循环外创建一次BaseFont和Font循环内复用。3.4 参数计算与字体子集化观察想验证字体是否真的嵌入了可以用一个简单方法生成 PDF 后看文件体积。如果模板原本 50KB填了中文后变成 200KB 左右说明字体子集嵌进去了。如果还是 50KB 出头那八成没嵌入成功。子集化的大小取决于用到的不同汉字数量。经验值是每 100 个不同汉字大约增加 30-50KB。一份含 500 个不同汉字的合同字体部分大概 150-250KB。如果发现 PDF 突然涨了好几 MB那可能是子集化没生效整个字体被全量嵌入了这时候要检查是不是每次写文字都新建了 BaseFont。还有一个观察点用 PDF 阅读器的文档属性或字体面板能看到嵌入的字体列表。如果看到类似ABCDEFSourceHanSansCN-Regular这样的名字前面带随机前缀说明子集化成功了那个前缀就是子集标识。4. 常见问题排查与避坑经验实录4.1 中文显示为方块或问号方块和问号是两种不同的故障要分开看。显示方块通常意味着字体嵌入了但字形映射错了。常见原因是用了.ttc文件却没指定正确的索引或者字体文件本身损坏。解决办法是换一个.ttf单字体文件测试排除 ttc 索引问题。显示问号一般是编码问题。检查BaseFont.createFont()的第二个参数是不是IDENTITY_H。如果误用了BaseFont.WINANSI中文就会变成问号或直接丢失。另外如果你从数据库或文件读取中文时用了错误的字符集比如把 UTF-8 当 GBK 读传进来的字符串本身就是乱码那字体再对也没用。4.2 部分中文显示、部分不显示这个现象很有意思通常出现在字体子集化出问题的时候。比如一份文档里甲方能显示乙方不显示。原因可能是你在写入文本之后才创建字体或者字体对象被多个 stamper 共享导致状态混乱。iText 的子集化是在stamper.close()时统一处理的它会扫描所有用该字体写入的文本。如果你在写入过程中动态创建了多个 BaseFont 实例指向同一个字体文件iText 可能把它们当成不同字体处理导致子集不完整。解决办法是全局复用一个 BaseFont 实例。还有一种情况是字体文件本身缺少某些生僻字。比如某些精简版字体只覆盖了 GB2312 字符集遇到生僻人名、异体字就没有字形。这时候要换一个覆盖更全的字体比如思源黑体、全字库正宋体这类。4.3 生成的 PDF 体积异常增大前面提过正常子集化后体积增长是可控的。如果 PDF 从几百 KB 涨到十几 MB基本可以确定是字体全量嵌入了。排查方向检查是否在循环里反复createFont导致同一字体被嵌入多次。检查是否用了BaseFont.NOT_EMBEDDED之外的错误组合。检查是否同时嵌入了多个中文字体比如标题用一个、正文用一个每个都全量嵌入。我遇到过一次极端情况一个批量生成证书的功能每张证书都新建 BaseFont结果 100 张证书的 PDF 合并后 80 多 MB。改成复用后降到 3MB 左右。字体对象复用是控制体积的第一要务。4.4 常见问题速查表现象可能原因排查方向解决方式中文完全消失用了无中文字形的内置字体检查 BaseFont 创建方式改用字体文件 IDENTITY_H EMBEDDED中文变问号编码参数错误检查第二个参数改为 BaseFont.IDENTITY_H中文变方块字形映射错误检查 ttc 索引、字体文件换 ttf 文件或修正索引部分中文缺失字体子集不完整检查 BaseFont 是否复用全局复用单个 BaseFontPDF 体积暴涨字体全量嵌入检查 createFont 调用位置循环外创建复用 Font部署后失效依赖系统字体路径检查字体加载方式改用 classpath 资源加载4.5 几个容易被忽略的实操心得心得一字体文件命名别用中文。有些环境对中文文件名处理有问题宋体.ttf可能在 Linux 上读不到。统一用英文名比如SourceHanSansCN-Regular.ttf。心得二注意字体的授权。商业字体比如某些厂商的黑体、宋体嵌入到 PDF 里分发可能涉及授权问题。对外产品建议用开源字体思源系列、文泉驿系列都是不错的选择授权清晰。心得三测试要覆盖生僻字。开发时用张三李四测试都能过上线后遇到龘靐齉这种生僻字就露馅了。测试用例里加几个生僻字能提前发现字体覆盖不全的问题。心得四ColumnText和PdfContentByte的坐标系。PDF 的坐标原点在左下角和很多 UI 框架的左上角原点相反。填内容时 y 坐标算错文字可能跑到页面外看起来也像不显示。这个坑和字体问题容易混淆排查时先确认坐标对不对。心得五增量更新与完全重写。PdfStamper默认是增量更新原 PDF 内容保留新内容叠加。如果你发现叠加的中文被原有内容盖住了用getOverContent()如果被盖住的是原有内容用getUnderContent()。这个选择不影响中文显示但影响最终效果。5. 进阶场景与性能优化建议5.1 大批量生成时的字体缓存策略当你要批量生成成千上万份 PDF 时每次都重新加载字体文件会成为性能瓶颈。字体文件解析本身不慢但反复 IO 和解析累积起来很可观。我的做法是做一个简单的字体缓存public class FontCache { private static final MapString, BaseFont CACHE new ConcurrentHashMap(); public static BaseFont getBaseFont(String path) throws Exception { return CACHE.computeIfAbsent(path, p - { try { return BaseFont.createFont(p, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); } catch (Exception e) { throw new RuntimeException(字体加载失败: p, e); } }); } }这样字体只解析一次后续复用。实测在批量生成场景下能省掉 30% 以上的时间。注意BaseFont在多线程下是否安全——iText 5.x 的BaseFont基本是只读的多线程共享读取一般没问题但如果你在写入过程中动态修改字体状态就要加同步。5.2 多字体混排的处理实际文档里经常需要标题用粗体、正文用常规体甚至中英文混排用不同字体。这时候要注意每个字体都要独立嵌入但都要复用。不要为每个字段创建新字体而是预先创建好标题字体、正文字体两个 BaseFont然后按需取用。中英文混排还有个技巧如果英文字体和中文字体分开iText 会按字符自动切换。但更简单的做法是直接用覆盖中英文的字体比如思源黑体本身就含拉丁字符省去切换逻辑。5.3 与表单填充的配合如果模板是 AcroForm 表单用stamper.getAcroFields()填充时中文字体设置方式略有不同AcroFields fields stamper.getAcroFields(); fields.setFieldProperty(name, textfont, baseFont, null); fields.setField(name, 某某科技有限公司);这里setFieldProperty必须在setField之前调用否则字体设置不生效。这个顺序问题坑过不少人记住先设属性再填值。5.4 版本升级的注意事项iText 5.x 到 7.x 的 API 变化很大PdfStamper在 7.x 里对应的是PdfDocumentDocument的组合字体创建方式也完全不同。如果你现在用 5.5.10短期内不建议为了这个问题升级因为 5.x 的这套方案已经足够稳定。真要升级字体部分要整体重写BaseFont换成了PdfFontFactory编码参数也不再是IDENTITY_H这种写法。我在实际项目里的体会是iText 5.5.10 处理中文模板填充核心就三件事——字体文件走 classpath、编码用 IDENTITY_H、嵌入标志用 EMBEDDED再加上字体对象全局复用。把这四点做到位中文不显示的问题基本不会再出现。剩下的就是坐标计算、字段映射这些业务细节了。踩过几次坑之后你会发现这个问题看着玄乎其实原理很清晰一旦理解字体嵌入的机制排查起来就是按图索骥的事。
阅读完成 · 觉得有帮助?
咨询建站