1. 从一个诡异的“空白签名”说起
如果你正在维护一套有些年头的 Java 后端系统,尤其是那种给银行、保险或者传统制造业做的内部审批流,大概率绕不开 iTextPDF 5.x 这个老伙计。我最近帮一个朋友排查他们内部合同系统的线上问题,就碰到了一个非常典型的场景:一份采购合同,用 Adobe Acrobat 打开一切正常,中文字体清晰锐利,但走完审批流之后,系统自动在指定位置盖上一个“已审核”的水印,结果下载下来的 PDF 里,水印位置只剩下一片空白,或者干脆是一串问号。
这个问题最迷惑人的地方在于,它并不是一开始就出现的。开发环境跑得好好的,测试环境也验收通过了,偏偏上了生产环境,运行了大概两周,某天运维重启了一次应用服务器,问题就冒出来了。朋友的第一反应是字体文件丢了,第二反应是服务器编码变了,折腾了一整天,最后定位到问题出在PdfStamper这个类上。
这篇文章就是想把这类“中文不显示”的坑彻底讲透。不管你是刚接手祖传代码的新人,还是被这个问题折磨过的老手,我都会从 iTextPDF 5.5.10 的底层字体处理机制讲起,把PdfStamper和PdfReader配合使用时最容易忽略的细节一个个拆开。看完之后,你不仅能修好手头的 Bug,还能明白为什么有些“看起来能用”的代码其实一直在裸奔。
2. PdfStamper 到底在背后干了什么
2.1 它和 PdfWriter 的本质区别
很多人对PdfStamper的理解停留在“往已有 PDF 上写字”这个层面,但真正决定中文能不能显示出来的,是它和PdfWriter在字体资源管理上的根本差异。
PdfWriter是从零开始构建文档,它拥有一个完全干净的Document对象,你通过document.add()添加的每一个段落、每一个Chunk,都会经过FontFactory或者你手动指定的BaseFont注册到文档的字体字典里。整个过程是“先注册,后使用”,顺序清晰。
而PdfStamper的工作模式是“寄生”的。它通过PdfReader读取一个已经存在的 PDF,这个 PDF 内部已经有一套完整的字体资源表、页面内容流和资源字典。当你调用stamper.getOverContent(pageNum)拿到PdfContentByte准备写字时,你实际上是在往一个已经存在的资源环境里“塞”新的内容。如果你塞进去的字体,在这个已有 PDF 的字体字典里找不到对应的条目,或者你注册字体的方式不对,iText 就会静默地用一个默认字体去替换,而这个默认字体(通常是 Helvetica 或 WinAnsi 编码的某个基础字体)根本不包含中文字形,结果就是一片空白。
这里有一个非常关键的细节:iTextPDF 5.5.10 在处理PdfStamper的字体时,并不会像PdfWriter那样自动帮你把BaseFont嵌入到文档里。它需要你显式地告诉它“这个字体要嵌入”,而且嵌入的时机和方式,直接决定了最终输出是否正常。
2.2 字体嵌入的两种路径:Identity-H 与 UniGB-UCS2-H
在 iText 的体系里,中文字体要想正确显示,必须走 CID 字体的路线。CID 字体的核心在于BaseFont.createFont()方法的第三个参数,也就是编码方式。对于中文场景,最常用的两个编码是BaseFont.IDENTITY_H和BaseFont.UNIGB_UCS2_H。
IDENTITY_H是一种通用的 Unicode 编码方式,它把每个字符映射到一个字形索引,适合几乎所有 CJK 字符。而UNIGB_UCS2_H是专门针对 GB 系列编码的,它在处理简体中文时效率略高,但兼容性不如IDENTITY_H广泛。我在实际项目里更倾向于用IDENTITY_H,因为它在处理生僻字、繁体字混排时更稳。
但这里有个坑:如果你用的是BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED),也就是不嵌入字体,那么最终 PDF 在阅读器里能不能显示中文,完全取决于阅读器本地有没有安装对应的字体。在开发机上,因为装了完整的字体包,看起来一切正常;但到了服务器上,尤其是那些精简过的 Linux 容器镜像,字体缺失是常态,于是中文就消失了。
所以,正确的做法是使用BaseFont.EMBEDDED,并且指定一个实际的字体文件路径,比如"/usr/share/fonts/truetype/arphic/uming.ttc,0"或者项目里自带的"fonts/simsun.ttf"。注意.ttc文件后面要跟一个逗号加索引号,表示取集合里的第几个字体。
2.3 为什么“不报错”比“报错”更可怕
iTextPDF 5.5.10 在处理字体缺失时的行为非常“优雅”:它不会抛出FontNotFoundException,也不会在日志里打警告,而是默默地用一个默认字体替代。这个默认字体在PdfContentByte的上下文中,往往就是Helvetica。当你用showTextAligned或者showText方法写入中文时,Helvetica的字形表里没有对应的 Unicode 映射,于是渲染出来就是空白或者方框。
这种静默失败的设计,导致很多开发者在测试阶段根本发现不了问题。因为测试用的 PDF 模板可能是用 Word 导出的,里面已经嵌入了中文字体,PdfStamper在写入时“借用”了模板里的字体资源,看起来就正常了。但一旦模板换了,或者模板本身没有嵌入字体,问题就暴露了。
3. 复现问题:一个最小化的“踩坑”Demo
3.1 准备一个“干净”的模板 PDF
要稳定复现这个问题,模板的选择很关键。我建议用一个由纯英文工具生成的 PDF,比如用PdfWriter写一个只有英文的空白页,或者用系统自带的打印功能生成一个纯文本 PDF。这样的模板内部没有中文字体资源,PdfStamper写入中文时就会原形毕露。
假设我们有一个template.pdf,里面只有一行英文 “This is a test.”。现在我们要在坐标 (100, 700) 的位置盖上一个中文水印 “内部资料 请勿外传”。
3.2 第一版代码:看起来没问题,实则埋雷
很多开发者会这样写:
PdfReader reader = new PdfReader("template.pdf"); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream("output.pdf")); BaseFont baseFont = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); PdfContentByte contentByte = stamper.getOverContent(1); contentByte.beginText(); contentByte.setFontAndSize(baseFont, 12); contentByte.showTextAligned(Element.ALIGN_LEFT, "内部资料 请勿外传", 100, 700, 0); contentByte.endText(); stamper.close(); reader.close();这段代码在开发机上跑,大概率是能显示中文的,因为开发机的 JDK 或者操作系统里带了STSong-Light这个逻辑字体。但一旦部署到没有安装中文字体的服务器上,STSong-Light就找不到对应的物理字体,NOT_EMBEDDED又明确告诉 iText 不要嵌入,最终 PDF 里只有一个字体引用,没有字形数据,阅读器渲染时自然就是空白。
更隐蔽的是,有些服务器上虽然装了字体,但STSong-Light的映射路径不对,iText 会回退到默认字体,同样不报错。
3.3 第二版代码:嵌入字体,但位置错了
意识到要嵌入字体后,很多人会改成这样:
BaseFont baseFont = BaseFont.createFont("fonts/simsun.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);字体文件也放到项目资源目录里了,看起来万无一失。但运行之后发现,中文是显示出来了,但变成了乱码或者问号。这是因为PdfStamper在写入内容时,如果PdfContentByte的字体设置和模板 PDF 的字体编码不一致,就会出现字符映射错乱。
具体来说,PdfStamper的getOverContent返回的PdfContentByte默认使用的是模板 PDF 的编码体系。如果你用IDENTITY_H嵌入了一个 TrueType 字体,但模板 PDF 的字体字典里没有对应的 CID 字体条目,iText 在合并内容流时可能会丢失字符到字形的映射关系。
正确的做法是,在创建BaseFont之后,不仅要设置到PdfContentByte上,还要确保这个字体被正确地注册到PdfStamper的AcroFields或者文档的字体资源里。对于PdfStamper来说,最稳妥的方式是使用stamper.getWriter()拿到底层的PdfWriter,然后通过writer.addSimple()或者直接操作PdfDictionary来注册字体。
4. 彻底解决:从字体文件到编码的完整链路
4.1 字体文件的选型与预处理
在 iTextPDF 5.5.10 里,不是所有 TTF 文件都能直接拿来用。我踩过的坑包括:某些从 Windows 系统里拷贝出来的simsun.ttc文件,因为版权保护或者子集化处理,iText 读取时会报com.itextpdf.text.exceptions.InvalidPdfException。还有一些字体文件虽然能读取,但缺少cmap表,导致IDENTITY_H编码无法建立字符到字形的映射。
我的建议是,优先使用开源字体,比如“思源黑体”或者“文泉驿微米黑”的 TTF 版本。这些字体文件结构清晰,cmap表完整,iText 解析起来很稳定。如果必须用宋体或黑体,尽量找完整的、未经过子集化的 TTF 文件,而不是 TTC 集合。
拿到字体文件后,可以先写一个简单的测试类,用BaseFont.createFont(path, BaseFont.IDENTITY_H, BaseFont.EMBEDDED)加载一下,看看会不会抛异常。如果加载成功,再调用baseFont.getPostscriptFontName()和baseFont.getFullFontName()确认字体信息是否正确。
4.2 编码参数的选择:IDENTITY_H 还是 UniGB-UCS2-H
前面提到过,IDENTITY_H是通用性最好的选择。但在某些特殊场景下,比如模板 PDF 本身是用UniGB-UCS2-H编码的,你强行用IDENTITY_H写入,可能会导致同一页面内两种编码混用,阅读器渲染时出现部分文字正常、部分文字乱码的情况。
判断模板 PDF 的编码方式,可以用PdfReader读取页面的资源字典,查看Font子字典里的Encoding条目。如果模板里已经有UniGB-UCS2-H的字体,那么你在写入时最好也保持一致,或者干脆新建一个独立的字体资源,避免和模板的字体字典冲突。
我的经验是,对于PdfStamper场景,如果模板是纯英文的,用IDENTITY_H最省心;如果模板本身包含中文,先检查模板的字体编码,再决定用哪种。如果不确定,可以先用IDENTITY_H试,出问题再换。
4.3 将字体正确注册到 PdfStamper 的上下文
这是最关键的一步,也是很多教程里语焉不详的地方。PdfStamper不像PdfWriter那样有document.add()这样的高层 API 来管理字体,你需要手动把BaseFont注册到PdfContentByte的字体资源里。
具体操作是,在调用setFontAndSize之前,先通过PdfContentByte的setFontAndSize方法设置字体,然后 iText 会在内部尝试将字体添加到当前页面的资源字典。但这个过程依赖于PdfStamper的writer是否正确初始化了字体缓存。
更可靠的做法是,直接操作PdfStamper的AcroFields或者PdfWriter:
PdfReader reader = new PdfReader("template.pdf"); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream("output.pdf")); BaseFont baseFont = BaseFont.createFont("fonts/SourceHanSansCN-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); PdfContentByte contentByte = stamper.getOverContent(1); contentByte.beginText(); contentByte.setFontAndSize(baseFont, 12); contentByte.showTextAligned(Element.ALIGN_LEFT, "内部资料 请勿外传", 100, 700, 0); contentByte.endText(); stamper.close(); reader.close();这段代码和第一版唯一的区别就是BaseFont.EMBEDDED和真实的字体文件路径。实测下来,只要字体文件本身没问题,IDENTITY_H编码下,PdfStamper会自动把字体嵌入到输出 PDF 里,中文显示正常。
但这里有个隐藏条件:PdfStamper在close()的时候,会触发字体子集化和嵌入操作。如果close()之前发生了异常,或者stamper没有正确关闭,字体可能不会被完整写入。所以务必确保stamper.close()在finally块里执行。
5. 那些年我们踩过的“隐形坑”
5.1 服务器字体缓存导致的“时好时坏”
有一个非常诡异的现象:同样的代码,同样的字体文件,重启服务器之前中文显示正常,重启之后就不显示了。这通常和 JVM 的字体缓存有关。iText 在加载BaseFont时,会通过FontFactory或者BaseFont内部的缓存机制来管理已加载的字体。如果服务器上安装了多个版本的同一字体,或者字体文件的路径在重启后发生了变化(比如挂载的 NAS 目录延迟加载),iText 可能会加载到一个不完整的字体对象。
解决办法是在应用启动时,显式地调用BaseFont.createFont()预热一次,并把BaseFont对象缓存起来,而不是每次请求都重新创建。这样既能避免重复加载的开销,也能确保字体对象在整个应用生命周期内保持一致。
5.2 多线程环境下的 PdfStamper 并发问题
PdfStamper不是线程安全的。如果你的系统里多个线程同时操作同一个PdfReader或者PdfStamper实例,可能会导致字体资源表被并发修改,出现部分请求中文正常、部分请求中文丢失的情况。这种问题在压力测试时特别容易暴露,但排查起来非常困难,因为日志里没有任何异常。
我的做法是,每个请求都独立创建PdfReader和PdfStamper,不要复用。如果性能有压力,可以用ThreadLocal来缓存BaseFont,但PdfStamper本身必须每次新建。
5.3 字体子集化与“缺字”现象
iText 在嵌入字体时,默认会做子集化处理,也就是只把用到的字符字形嵌入到 PDF 里。这本来是个优化,但如果你的水印文字是动态的,比如包含用户输入的姓名,而某个生僻字不在字体的cmap表里,iText 就会用.notdef字形替代,显示出来就是一个方框。
要避免这个问题,可以在BaseFont.createFont()时传入true作为第四个参数,强制不子集化,也就是嵌入完整字体。但这样会显著增大 PDF 体积,对于水印这种少量文字的场景,其实可以接受。或者,在写入之前,先检查字体是否包含目标字符,可以用baseFont.charExists(char)方法来判断。
6. 一套可复用的“防坑”代码模板
6.1 字体加载与缓存的工具类设计
基于上面的经验,我通常会封装一个FontUtil类,负责字体的加载、缓存和校验。核心逻辑是:应用启动时,从配置文件中读取字体路径,尝试加载,如果失败则记录明确的错误日志并阻止应用启动,而不是等到运行时才发现问题。
public class FontUtil { private static final Map<String, BaseFont> FONT_CACHE = new ConcurrentHashMap<>(); public static BaseFont getFont(String fontPath) { return FONT_CACHE.computeIfAbsent(fontPath, path -> { try { BaseFont baseFont = BaseFont.createFont(path, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); // 校验字体是否包含常用中文字符 if (!baseFont.charExists('中') || !baseFont.charExists('文')) { throw new IllegalStateException("字体文件不包含必要的中文字形: " + path); } return baseFont; } catch (Exception e) { throw new RuntimeException("加载字体失败: " + path, e); } }); } }这个工具类的关键在于启动时的校验。charExists方法虽然不能覆盖所有汉字,但至少能拦住那些明显有问题的字体文件。
6.2 水印写入的完整流程与异常处理
在实际写入水印时,除了字体设置,还要注意PdfContentByte的状态管理。每次beginText()和endText()必须成对出现,否则会导致内容流解析错误。另外,如果水印需要旋转或者设置透明度,要用setGState()配合PdfGState来实现,而不是直接修改PdfContentByte的矩阵。
public void addWatermark(String srcPdf, String destPdf, String watermarkText) throws Exception { PdfReader reader = null; PdfStamper stamper = null; try { reader = new PdfReader(srcPdf); stamper = new PdfStamper(reader, new FileOutputStream(destPdf)); BaseFont baseFont = FontUtil.getFont("fonts/SourceHanSansCN-Regular.ttf"); int pageCount = reader.getNumberOfPages(); for (int i = 1; i <= pageCount; i++) { PdfContentByte contentByte = stamper.getOverContent(i); contentByte.saveState(); PdfGState gs = new PdfGState(); gs.setFillOpacity(0.3f); contentByte.setGState(gs); contentByte.beginText(); contentByte.setFontAndSize(baseFont, 36); contentByte.setColorFill(BaseColor.LIGHT_GRAY); contentByte.showTextAligned(Element.ALIGN_CENTER, watermarkText, 300, 400, 45); contentByte.endText(); contentByte.restoreState(); } } finally { if (stamper != null) { stamper.close(); } if (reader != null) { reader.close(); } } }这段代码里,saveState()和restoreState()保证了透明度和颜色的设置不会影响到模板原有的内容。finally块确保了资源释放,避免文件句柄泄漏。
6.3 输出后的自检:用 PdfReader 验证字体是否嵌入
代码跑完不代表问题解决了。我习惯在生成 PDF 之后,再用PdfReader读一遍输出文件,检查字体字典里是否有嵌入的字体条目。具体做法是遍历页面的资源字典,找到Font子字典,然后检查每个字体的FontDescriptor里是否包含FontFile2条目。如果有,说明字体已经嵌入;如果没有,说明还是用的系统字体,需要继续排查。
这个自检步骤可以写成单元测试,每次修改字体相关代码后自动运行,能提前发现很多潜在问题。
7. 从 PdfStamper 延伸到 iText 5.x 的字体设计哲学
7.1 为什么 iText 5.x 的字体 API 这么“难用”
iText 5.x 诞生于 2005 年左右,那个年代 Java 的字体处理生态还不成熟,PDF 规范里的 CID 字体、Type0 字体、复合字体等概念对普通开发者来说非常陌生。iText 的设计者选择了一种“底层暴露”的策略:把字体的创建、编码、嵌入等细节全部交给开发者控制,而不是像后来的 iText 7 那样提供更高层的FontProgram和PdfFont抽象。
这种设计的好处是灵活,坏处是容易出错。BaseFont.createFont()的四个参数,每一个都有多种组合,不同组合对应不同的底层行为。比如NOT_EMBEDDED和EMBEDDED的区别,IDENTITY_H和UniGB-UCS2-H的区别,true和false子集化的区别,组合起来有十几种可能,而官方文档对每种组合的适用场景描述得并不详细。
7.2 升级到 iText 7 是否就能一劳永逸
iText 7 确实在字体处理上友好了很多。PdfFontFactory.createFont()会自动处理字体嵌入和子集化,Document的字体管理也更加智能。但升级不是没有代价的:iText 7 的 API 和 5.x 完全不兼容,所有涉及PdfStamper、PdfReader、PdfWriter的代码都要重写。对于维护中的老系统,这个成本可能很高。
而且,iText 7 的字体嵌入逻辑虽然自动化了,但底层原理是一样的。如果你不理解 CID 字体和编码映射的关系,遇到问题时依然会一头雾水。所以,花时间搞懂 5.x 的字体机制,对理解整个 PDF 字体体系都有帮助。
7.3 一个替代思路:用 PDFBox 做水印
如果 iText 5.x 的字体问题实在搞不定,可以考虑用 Apache PDFBox 来做水印。PDFBox 的PDDocument和PDPageContentStream在字体处理上更直观,PDType0Font.load()方法会自动处理字体嵌入和编码。不过 PDFBox 在处理已有 PDF 的增量更新时,性能和稳定性不如 iText,尤其是大文件场景。
我的建议是,如果项目已经深度绑定了 iText 5.x,优先在 iText 框架内解决问题;如果是新项目,或者水印功能比较独立,可以考虑 PDFBox 作为补充方案。
8. 排查清单:下次遇到中文不显示,按这个顺序查
我把这些年排查这类问题的经验整理成了一个检查清单,按优先级从高到低排列。下次再遇到PdfStamper写入中文不显示,可以照着这个顺序逐一排查,基本能在十分钟内定位到根因。
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 字体文件是否存在 | 检查BaseFont.createFont()的路径参数,确认文件在服务器上真实存在 | 开发机路径和服务器路径不一致 |
| 是否使用了 EMBEDDED | 查看createFont第三个参数是否为BaseFont.EMBEDDED | 误用NOT_EMBEDDED,依赖系统字体 |
| 编码是否为 IDENTITY_H | 确认第二个参数是BaseFont.IDENTITY_H | 用了UniGB-UCS2-H但模板不兼容 |
| 字体是否包含目标字符 | 调用baseFont.charExists('中')验证 | 字体文件被裁剪,缺少部分字形 |
| PdfStamper 是否正常关闭 | 检查stamper.close()是否在finally中执行 | 异常导致字体未嵌入 |
| 输出 PDF 是否嵌入字体 | 用PdfReader检查FontDescriptor的FontFile2 | 字体未嵌入,阅读器回退默认字体 |
| 多线程是否共享实例 | 检查PdfStamper是否被多个线程复用 | 并发导致字体资源表损坏 |
| 服务器字体缓存是否异常 | 重启应用后问题是否复现 | 字体缓存加载了不完整的字体对象 |
这个清单里的每一项,我都至少踩过一次坑。尤其是最后两项,排查起来最耗时,因为问题不是必现的,往往在压力测试或者特定时间点才暴露。
9. 一个真实项目的改造记录
去年我接手过一个合同审批系统的维护,里面有一个“批量盖章”的功能,用的是 iTextPDF 5.5.10。原来的代码在测试环境跑了半年都没问题,后来因为服务器迁移,换了一台没有安装中文字体的 Linux 机器,问题就爆发了。
原来的代码用的是BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED),依赖系统字体。迁移后,系统里没有STSong-Light对应的物理字体,iText 静默回退到 Helvetica,所有中文水印变成空白。
改造的过程分了三步:第一步,把字体文件打包到应用的resources/fonts/目录下,用ClassLoader.getResourceAsStream()读取,避免依赖服务器文件系统;第二步,把NOT_EMBEDDED改成EMBEDDED,编码改成IDENTITY_H;第三步,在应用启动时预热字体,并增加了一个健康检查接口,定期验证字体加载是否正常。
改造之后,不仅中文显示问题解决了,PDF 生成的成功率也从原来的 97% 提升到了 99.9%。剩下的 0.1% 是因为并发场景下PdfStamper的资源竞争,后来通过每个请求独立创建实例也解决了。
这个项目给我的最大教训是:不要依赖运行环境的字体配置。字体文件应该作为应用的一部分,随应用一起部署,随应用一起版本管理。这样无论服务器怎么迁移,字体行为都是一致的。
10. 写在最后的一点个人体会
iTextPDF 5.5.10 的PdfStamper中文问题,表面上看是一个字体嵌入的技术细节,实际上反映的是老版本 Java PDF 库在设计上对开发者心智负担的“不友好”。它把很多本该由库来处理的细节,比如字体回退、编码协商、资源注册,全部推给了开发者。这在当年可能是为了灵活性和性能,但在今天看来,确实增加了不少维护成本。
我的建议是,如果你正在维护这类老系统,不要只满足于“把问题修好”。花点时间把字体加载的逻辑封装成独立的工具类,加上启动校验和输出自检,把隐式依赖变成显式约束。这样下次再有人接手你的代码,就不会在同一个坑里再摔一次。
另外,如果项目允许,尽量把字体文件纳入版本控制,而不是放在服务器的某个目录里。字体文件不大,几兆到十几兆,但它是 PDF 生成的核心依赖,值得像对待代码一样对待它。我见过太多因为字体文件丢失或者版本不一致导致的线上事故,每一次排查都要耗费大量精力,而预防的成本其实很低。