news 2026/10/16 5:30:36

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
iTextPDF 5.5.10 PdfStamper 中文不显示?字体嵌入原理与实战修复

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 Font,Type 0),它支持多字节编码。IDENTITY_H就是 iText 里对应复合字体的一种编码方式,它用 CID(Character ID)来索引字形,配合UniGB-UCS2-H或UniGB-UTF16-H这样的 CMap 做字符到 CID 的映射。

简单说:IDENTITY_H是让 iText 用"字形 ID"而不是"字符编码"来组织字体的开关,中文必须走这条路。如果你用BaseFont.WINANSI或GBK之类的编码去创建中文字体,要么直接抛异常,要么生成一个残缺的字体,中文照样显示不出来。

这里有个细节值得注意:IDENTITY_H和IDENTITY_V的区别是横排和竖排。绝大多数场景用IDENTITY_H(H 代表 Horizontal)就够了,除非你要做古籍竖排那种特殊排版。

3. 完整实操:从零复现并解决中文不显示

3.1 环境准备与依赖确认

先把环境理清楚。iTextPDF 5.5.10 的 Maven 坐标是:

<dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>5.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_EMBEDDED,iText 会假设阅读器本地有这个字体,但阅读器大概率没有,中文照样不显示。所以中文字体必须EMBEDDED。

第三,Font对象要复用。如果你在一个循环里给多个字段填中文,不要在循环里反复createFont,那会导致字体被重复嵌入,PDF 体积暴涨。正确做法是在循环外创建一次BaseFont和Font,循环内复用。

3.4 参数计算与字体子集化观察

想验证字体是否真的嵌入了,可以用一个简单方法:生成 PDF 后看文件体积。如果模板原本 50KB,填了中文后变成 200KB 左右,说明字体子集嵌进去了。如果还是 50KB 出头,那八成没嵌入成功。

子集化的大小取决于用到的不同汉字数量。经验值是:每 100 个不同汉字大约增加 30-50KB。一份含 500 个不同汉字的合同,字体部分大概 150-250KB。如果发现 PDF 突然涨了好几 MB,那可能是子集化没生效,整个字体被全量嵌入了,这时候要检查是不是每次写文字都新建了 BaseFont。

还有一个观察点:用 PDF 阅读器的"文档属性"或"字体"面板,能看到嵌入的字体列表。如果看到类似ABCDEF+SourceHanSansCN-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 是否复用全局复用单个 BaseFont
PDF 体积暴涨字体全量嵌入检查 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 Map<String, 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 里对应的是PdfDocument+Document的组合,字体创建方式也完全不同。如果你现在用 5.5.10,短期内不建议为了这个问题升级,因为 5.x 的这套方案已经足够稳定。真要升级,字体部分要整体重写,BaseFont换成了PdfFontFactory,编码参数也不再是IDENTITY_H这种写法。

我在实际项目里的体会是:iText 5.5.10 处理中文模板填充,核心就三件事——字体文件走 classpath、编码用 IDENTITY_H、嵌入标志用 EMBEDDED,再加上字体对象全局复用。把这四点做到位,中文不显示的问题基本不会再出现。剩下的就是坐标计算、字段映射这些业务细节了。踩过几次坑之后你会发现,这个问题看着玄乎,其实原理很清晰,一旦理解字体嵌入的机制,排查起来就是按图索骥的事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 5:29:56

ZooKeeper容灾与脑裂防范:多数派机制、CAP权衡及生产配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 5:29:40

邮局选址问题:从组合优化建模到遗传算法与模拟退火实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 5:28:55

51单片机LED点阵显示原理与取字模软件使用教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 5:28:38

C#高固运动控制卡demo源码解析:从初始化到点位运动实战

简介&#xff1a;这份C#高固运动控制卡demo源码面向自动化控制、机器人及精密机械领域的C#开发者&#xff0c;帮助其在.NET环境下快速掌握高精度运动控制卡的编程方法&#xff0c;解决从通信建立到实时运动控制的落地问题。资源包共38个文件&#xff0c;约274KB&#xff0c;以1…

作者头像 李华
网站建设 2026/10/16 5:28:26

两串电池被动均衡设计:MP2642GR-Z与STM32F302R8实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 5:27:52

2S锂电池主动均衡方案:MP2642GR-Z与R7FA2E2A72DNK实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华