一份十来字段的登记表模板,从系统里把数据灌进去,听上去是半小时能收工的活。真上手才发现,英文字段一切正常,值里一出现中文,输出的 PDF 要么整段空白,要么一排空心方块,运气差一点直接抛异常中断。我前后在三四个项目里踩过同一类坑,问题从来不是“编码写错了”这么简单——iText / iText7 填充 PDF 表单域这件事,卡点集中在两处:字段外观流的重绘机制,以及中文字体有没有真正嵌进文件里。下面这份内容就是把这套链路从头到尾拆开讲,包括版本选型、字体处理、各类控件的取值规则、压平交付,以及几个我实际排查过的故障现场。适合已经能把 PDF 打开但填不对、或者正准备接这类需求的人看,前端后端都能对上号。
1. 中文字符填进表单域就变方块,问题不在编码
很多人第一反应是去查 UTF-8、GBK、Unicode 转义,写一堆new String(bytes, "UTF-8"),改半天没变化。方向从一开始就偏了。PDF 里的文字能不能显示,取决于这个字符在渲染时用的字体里有没有对应的字形,跟 Java 侧的字符串编码关系不大。字符串进了 iText 之后统一按 Unicode 处理,真正的分岔口在后面:字体对象。
1.1 字段外观是预渲染的“快照”,不是活文本
PDF 表单域跟 Word 里的文本框完全不是一回事。你在 Acrobat 里敲字,实际上是阅读器在实时生成一段外观流(Appearance Stream),把文字画成矢量图形贴到页面上。表单域本身只存两样东西:值(/V)和外观(/AP)。
这个设计带来的直接后果是:你只改值不改外观,很多阅读器就会显示空白或者旧值。iText 的setValue()在某些版本里会顺手把外观重绘一次,但只要你中途动过字体、字号、颜色,就必须手动再触发一次regenerateField()。我见过最典型的现象是:用代码读字段值读出来是对的,用阅读器打开却是空的——因为值写进去了,外观流没跟上。
普通拉丁字母之所以“看起来正常”,是因为模板默认字体通常是 Helvetica,属于 PDF 规范里的 14 种标准字体,阅读器内置,永远不缺字形。而中文字体动辄十几兆,不可能内置,必须由你的代码提供,并且嵌入到输出文件里。
1.2 编码选对了只是第一步,字体没嵌入照样白搭
PdfEncodings.IDENTITY_H这行参数几乎每个教程都会写,它的作用是告诉 iText 用 CID 字体 + Identity-H 编码来组织字形索引,这是处理中日韩字符的正确姿势。但只写这一句、字体却用了非嵌入方式,结果依然是方块。
原因在于非嵌入字体只写了个字体名到 PDF 里,指望阅读器自己去系统里找同名字体。桌面端可能碰巧找到,手机端、打印服务端、下游归档系统一律找不到,直接回退成空白或方块。所以判断标准很简单:输出文件的字体必须内嵌。用 Acrobat 的“文件 → 属性 → 字体”看一眼,如果中文字体那一栏后面没有“(嵌入)”字样,那这个文件在别人机器上随时会翻车。
我在一个批量生成证明文件的项目里就吃过这个亏。开发机上一切正常,因为本机装了对应字体;部署到服务器上全是方块,排查了大半天才定位到字体没嵌入、服务器又没装字体,双重因素叠加。后来统一改成显式指定字体文件路径 + 强制嵌入,问题一次解决。
2. iText5 与 iText7 的分水岭:先摸清手上的模板
选版本这件事,很多人是看公司现有依赖里有什么就用什么,这没错,但如果是从零开始,值得花十分钟把两个版本的差异捋清楚,因为它们的 API 结构差异很大,中途换版本基本等于重写。
2.1 两代 API 的差异会直接改写你的代码结构
iText5 的核心是PdfStamper+AcroFields,写法非常直白:拿到 stamper,拿到 form,setField(name, value)一行一个字段。它的心智模型是“在已有 PDF 上盖章”。iText7 换成了PdfDocument+PdfAcroForm+PdfFormField的对象模型,字段变成了有类型的对象,文本域、按钮、选择框各有各的类,赋值方式和参数校验都不一样。
从工程角度看,iText7 的优势在于类型安全和异常提示清晰,字段类型不对会直接抛出可读的异常,不像 iText5 那样静默失败;而且 iText7 的许可证是 AGPL / 商业双轨,iText5 也是类似的双轨模式,这块需要按项目性质自行确认,别默认当成纯免费库用。
我这里给个粗略的判断:存量项目用 iText5 就继续用,新项目直接上 iText7 7.2.x。7.2 系列对字体嵌入策略做了显式化改造,EmbeddingStrategy这个枚举把“要不要嵌入”变成了必须表态的参数,反而减少了踩坑概率。
2.2 AcroForm 与 XFA:能不能填,模板说了算
比版本更容易被忽略的是模板类型。PDF 表单有两套体系:AcroForm(传统表单,字段存在/AcroForm字典里)和 XFA(XML Forms Architecture,字段定义在一段 XML 里)。
iText 的PdfAcroForm只处理前者。判断方法很简单:
PdfAcroForm form = PdfAcroForm.getAcroForm(pdfDoc, false); if (form != null && form.getXfa() != null && !form.getXfa().isNull()) { // 这是动态 XFA 表单,AcroForm 接口填不动 }拿到 XFA 表单,getFormFields()大概率返回空 Map,或者只返回一层壳。这种情况下的可行路径只有一条:用带排版能力的工具把模板“打印”成静态 PDF(相当于压平),再按普通 AcroForm 处理;或者请模板提供方重新导出一份 AcroForm 版本。指望代码硬填 XFA,性价比极低,我在两个项目里都选择了让业务方换模板,比写代码省事得多。
注意:判断模板类型这一步应该放在项目启动阶段,而不是写完填充逻辑之后。我见过写完两周代码才发现模板是 XFA 的情况,返工成本很高。
3. 动手前把资源备齐:依赖、字体、模板体检
准备工作看起来琐碎,但它决定了后面 80% 的调试时间。我的习惯是先写一个“模板体检”小程序,把字段名、字段类型、当前值、选项列表全部打印出来,这份清单后面会反复用到。
3.1 依赖坐标与版本对齐
iText7 的 Maven 坐标建议用聚合包,省得逐个引:
<dependency> <groupId>com.itextpdf</groupId> <artifactId>itext7-core</artifactId> <version>7.2.5</version> <type>pom</type> </dependency>如果只需要表单相关能力,也可以只引kernel、layout、forms、io这几个模块,能省几兆体积。这里有个容易忽略的点:forms模块和kernel的版本号必须严格一致,混用不同小版本经常出现NoSuchMethodError,报错信息还指向完全无关的地方。
模板体检代码大概长这样:
try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(src))) { PdfAcroForm form = PdfAcroForm.getAcroForm(pdfDoc, false); if (form == null) { System.out.println("模板没有表单域"); return; } form.getFormFields().forEach((name, field) -> { System.out.printf("name=%s, type=%s, value=%s, readonly=%s%n", name, field.getFormType(), field.getValue() == null ? "" : field.getValue().toString(), field.isReadOnly()); }); }getFormType()返回的是PdfName,/Tx是文本域,/Btn是按钮类(含复选框、单选、普通按钮),/Ch是选择类,/Sig是签名域。这份输出打印出来,后续填哪些、怎么填就一目了然了。
3.2 中文字体的选择与体积控制
字体这块我踩过的坑最多,几条经验直接给出来:
第一,优先用可商用的开源中文字体,比如思源黑体、思源宋体的子集版本,或者各系统自带的常见字体文件,具体授权务必自行核对,不要想当然。第二,不要用.ttc字体集合文件时忘了索引,iText 读取 ttc 需要写成字体路径,索引的形式,漏了索引会报解析错误。第三,优先选单字重、字符集精简的版本,一个完整的中文字体文件动辄 15~20MB,虽然 iText 会自动做子集化(只嵌入用到的字符),但源文件越大,解析和子集化耗时越长,批量场景下这个开销会被放大。
关于子集化,这里解释一下原理:iText 嵌入字体时不会把整个字体文件塞进去,而是扫描文档里实际用到的字符,只保留这些字形以及必要的索引表。所以一份只填了姓名、部门、日期的表单,最终字体增量通常在几十 KB 级别,完全可以接受。前提是同一个 PdfDocument 内复用同一个 PdfFont 实例,如果每个字段都createFont()一次,会重复嵌入多份子集,文件体积翻倍。
同时提醒一个反向的坑:不要跨 PdfDocument 复用 PdfFont 对象。iText 的字体对象跟文档生命周期绑在一起,文档关闭后再拿旧字体去新文档里用,会抛出字体已被释放之类的异常,或者出现字形错乱。正确做法是在每个文档的处理流程内重新创建,或者用字体程序级别的缓存做二次封装。
4. iText7 填充文本域的完整链路
这部分给一套可以直接抄的骨架。我把它拆成打开、定位、赋值、刷外观四步,每一步都有必须传对的参数。
4.1 打开文档时的三个参数决定成败
String src = "templates/form.pdf"; String dest = "out/form_filled.pdf"; PdfReader reader = new PdfReader(src); PdfWriter writer = new PdfWriter(dest); StampingProperties props = new StampingProperties().useAppendMode(); PdfDocument pdfDoc = new PdfDocument(reader, writer, props);三个参数里最容易出错的是第三个。StampingProperties决定是“增量更新”还是“全量重写”。用useAppendMode()会在原文件尾部追加一次修订,原有内容一字不动,适合带数字签名、需要保留原始字节的场景;代价是文件会变大,而且一旦填错,历史修订会一层层累积。不带 append 模式则是完整重写一遍,文件更干净,但原有的签名会失效。
我的默认选择是不带 append,除非模板里明确有签名域需要保留。如果确实要保留签名,还要注意增量模式下不能修改已签名的页面内容,只能往表单域里写值。
还有一个必须提前想清楚的问题:读写不能是同一个路径。PdfReader打开文件后,PdfWriter如果指向同一个文件,会因为文件被截断而导致读到的内容为空,最终输出一个损坏或者 0 字节的文件。这个坑我在第 7 节会详细讲。
4.2 字段定位:名字对不上是最常见的报错源头
PdfAcroForm form = PdfAcroForm.getAcroForm(pdfDoc, true); Map<String, PdfFormField> fields = form.getFormFields(); PdfFormField field = fields.get("employeeName"); if (field == null) { throw new IllegalStateException("字段不存在: employeeName"); }几个必须知道的细节:
第一,字段名是带层级的。PDF 里字段可以嵌套,比如“员工信息”下面有“姓名”,完整名就是employee.name,用点号连接。做模板时如果用了层级命名,代码里就必须写全名,不能用短名。排查方法就是把第 3 节的体检程序跑一遍,把完整名抄下来。
第二,getFormFields()返回的不全是终端字段。带子字段的父字段也会出现在 Map 里,但给父字段赋值是没有意义的。遍历时要跳过:
if (field.getChildren() != null && !field.getChildren().isEmpty()) { continue; // 非终端字段,跳过 }第三,字段名大小写敏感,且可能含空格。从别的系统导出的模板,字段名里带空格、中文、特殊符号的情况我都见过,务必按原样匹配,不要手工“优化”成驼峰。
赋值本身很简单:
PdfTextFormField textField = (PdfTextFormField) field; textField.setValue("张三"); textField.setMultiline(true); // 值里有换行符时必须开 textField.setFontSize(10f); // 0 表示自动缩放如果字段本身不是多行域,值里塞了\n会被吞掉或者显示成乱码方块,所以要么提前把换行替换成空格,要么显式开启多行标志。这一点在处理“备注”“地址”这类字段时特别容易翻车。
4.3 把字体灌进字段外观
这是全文最核心的一段:
PdfFont font = PdfFontFactory.createFont( "fonts/SourceHanSansSC-Regular.otf", PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); for (PdfFormField field : fields.values()) { if (field.getChildren() != null && !field.getChildren().isEmpty()) continue; field.setFont(font); field.setFontSize(9f); field.regenerateField(); // 关键:重绘外观 }版本差异提醒:7.1.x 的createFont第三个参数是布尔值,7.2.x 改成了EmbeddingStrategy枚举。如果项目里锁定的是 7.1,写法要调整成createFont(path, PdfEncodings.IDENTITY_H, true),虽然方法被标记为过时但依然可用。跨版本混写是编译期就能发现的问题,不算大坑,真正麻烦的是不同小版本对regenerateField()的触发时机不一致,所以我习惯显式调用一次,不赌框架行为。
另外,setFontSize(0)在部分阅读器里表示“自动适配字段高度”,但在某些旧版阅读器上会退化成默认字号,跨端一致性不好。批量生成场景我一般直接写死 9 到 10 号,视觉上更稳定。
5. 复选框、单选组、下拉框:取值规则各不相同
文本域填完,剩下的控件才是真正让人抓狂的部分。它们的共同点是:能填什么值,不由你决定,由模板决定。
5.1 按钮类字段的导出值必须精确匹配
复选框听起来简单,但它的“选中”不是布尔值,而是一个叫**导出值(export value)**的字符串。模板设计者可能把它设成Yes、On、1、是,甚至是无意义的一串字符。你给setValue("true")大概率没反应,或者抛异常。
稳妥的做法是把可用导出值读出来:
PdfButtonFormField btn = (PdfButtonFormField) fields.get("agreeFlag"); Set<String> states = new LinkedHashSet<>(); for (PdfWidgetAnnotation widget : btn.getWidgets()) { PdfDictionary ap = widget.getPdfObject().getAsDictionary(PdfName.AP); if (ap == null) continue; PdfDictionary normal = ap.getAsDictionary(PdfName.N); if (normal == null) continue; normal.keySet().stream() .map(PdfName::getValue) .filter(s -> !"Off".equals(s)) .forEach(states::add); }排除Off之后剩下的就是可用的“开”状态。单选按钮组更要注意:同一组的多个 widget 共享一个字段名,但每个 widget 有自己的导出值,选中其实就是把字段值设成某个 widget 的导出值。如果两个选项导出值重复(模板复制粘贴常见),会导致两个一起被选中,这种只能改模板。
还有一类普通按钮(push button),它没有值,纯粹是触发脚本或跳转用的,代码里遇到/Btn类型要先看它有没有 widget 的 AP 字典,别一股脑当成复选框处理。
5.2 选择类字段的值必须落在选项集合里
下拉框和列表框对应PdfChoiceFormField。它的约束比复选框更强:赋的值必须已经存在于选项列表中,否则 iText7 会抛IllegalArgumentException,iText5 则是静默失败、字段保持为空。
先把选项读出来做校验:
PdfChoiceFormField combo = (PdfChoiceFormField) fields.get("city"); List<String> options = Arrays.asList(combo.getOptions()); if (!options.contains("杭州")) { // 要么回退到默认值,要么给模板补充选项 } combo.setValue("杭州");另外有个选项叫“允许用户自定义输入”(/Edit标志),带这个标志的下拉框可以填任意值。判断方法:
boolean editable = (combo.getFieldFlags() & PdfFormField.FF_COMBO) != 0 && (combo.getFieldFlags() & PdfFormField.FF_EDIT) != 0;不带/Edit的下拉框硬填不存在的值,除了报错还会让阅读器把字段显示成空白,排查起来很费劲。我的建议是在业务层做一次白名单校验,把“数据库里有这个城市但模板里没有这个选项”这类问题拦在生成阶段,而不是等文件生成完才被发现。
6. 交付环节:压平、只读与批量吞吐
文件填完不算完,交付形态决定了它在下游会不会出问题。
6.1 flatten 解决的是“下游不想装字体”的问题
form.flattenFields()会把所有字段的值渲染成页面上的普通内容,然后删掉表单结构。这个操作对我的意义是:彻底摆脱对阅读器字体环境的依赖。因为外观已经被画成矢量路径,谁来打开都一样。
代价也很明确:字段不再可编辑、不能再提取值、不能再做后续填充。所以顺序一定是“全部填完 → 最后压平”,中间不要压。
form.flattenFields(); pdfDoc.close();如果只想压平部分字段,可以传字段名集合;想保留表单但禁止编辑,就别压平,改成逐字段设只读:
fields.values().forEach(f -> f.setReadOnly(true));我个人在“归档类文件”上一律压平,在“需要下游继续填”的文件上一律只读,两种策略覆盖了绝大多数场景。
6.2 批量处理的资源与内存边界
批量场景(比如一次生成几千份)最容易出问题的是资源没关。PdfDocument、PdfReader、PdfWriter任何一个忘了关闭,文件句柄就会累积,跑到几百份就开始报“打开的文件太多”。
我习惯用 try-with-resources 包住PdfDocument,它关闭时会连带关闭 reader 和 writer:
for (Path template : templates) { try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(template.toString()), new PdfWriter(dest.toString()))) { // 填充逻辑 } }另一个建议是把输出先写到临时文件再原子移动,避免下游读到半成品。批量生成时如果某一份失败,不要让整个批次中断,用 try-catch 包住单个任务,记录失败的文件名和原因,最后统一重试。这套模式我在三个项目里都用过,比一次性失败重跑整个批次省太多时间。
7. 四个真实故障的排查链路
下面这几个故障我都实际遇到过,排查过程原样还原,希望能省下你的时间。
7.1 字段名明明对,取出来却是 null
现象:体检程序打印出来的字段名是name,代码里fields.get("name")却返回 null。
排查顺序是这样的:先怀疑 Map 的 key 带了不可见字符,把 key 用[]包起来打印长度;发现长度对不上,16 位 vs 4 位,多出来的是零宽字符。模板是从某个业务系统导出的,字段名里混进了不可见字符。解决办法是遍历时用trim()加正则清洗后再建索引,或者干脆改成按顺序匹配字段。
某高校的一个实验室做过类似的模板,几百个字段名里有一半带尾随空格,肉眼完全看不出来。这种问题只能靠打印长度或者转成 Unicode 码点来定位,光盯着屏幕看是看不出来的。
7.2 填完保存,源文件被清成 0 字节
现象:生成执行完没报错,输出文件是 0 字节,连模板本身都变成了 0 字节。
原因:PdfReader和PdfWriter指向同一个路径。PdfWriter构造时会立刻把目标文件截断为空,此时 reader 还没读完,等于把源文件清空了,随后读取失败、写入也失败。
修复方式有两种,我推荐第二种:
// 方式一:先读进内存 byte[] srcBytes = Files.readAllBytes(Paths.get(src)); PdfReader reader = new PdfReader(new ByteArrayInputStream(srcBytes)); PdfWriter writer = new PdfWriter(dest); // 方式二:写临时文件再原子替换 Path tmp = Files.createTempFile("filled_", ".pdf"); try (PdfDocument pdfDoc = new PdfDocument(new PdfReader(src), new PdfWriter(tmp.toString()))) { // 填充 } Files.move(tmp, Paths.get(dest), StandardCopyOption.REPLACE_EXISTING);这个坑的危险之处在于它会破坏源文件,如果模板是唯一的,损失就大了。所以我在所有涉及原地覆盖的场景里都强制走临时文件,宁可多一次 IO。
7.3 屏幕上正常,打印或下游系统里字没了
现象:本机阅读器打开一切正常,打印出来中文字符缺失,或者上传到下游归档系统后文字消失。
根本原因还是字体没嵌入。本机能显示是因为系统里恰好装了同名字体,阅读器回退到系统字体渲染;打印子系统或者下游服务器的字体环境不同,直接放弃渲染。
验证方法:用工具查看输出 PDF 的字体列表,看中文字体那一栏是否标注为嵌入。另一个佐证是文件体积——没嵌入字体的小文件往往只有几十 KB,嵌入之后会明显变大。
顺带说一句,PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED是“优先嵌入”,遇到字体本身禁止嵌入(有些商业字体的授权标志位就是这么设的)时会退化成不嵌入,而且不一定报错。所以生产环境我建议用FORCE_EMBEDDED,让它直接抛出来,早失败早发现。
7.4 加密文档与带签名的模板
加密模板表现为PdfReader构造时抛“文档已加密”或者读取字段全是空。处理方式是传入密码:
ReaderProperties rp = new ReaderProperties().setPassword("yourPassword".getBytes(StandardCharsets.UTF_8)); PdfReader reader = new PdfReader(new ByteArrayInputStream(srcBytes), rp);密码在这里是明文传入的,注意别硬编码在代码里,走配置或者密钥管理。另外,某些加密文档设置了“禁止填写表单”的权限位,这种情况下即使能打开也写不进去,需要先确认权限。
签名域的情况前面提过:带数字签名的模板必须用 append 模式写入,否则签名失效。而且增量模式下每次填充都会新增一个修订版本,文件会逐次膨胀。如果只是内部流转用,我的做法是先确认签名是否可以舍弃,能舍弃就直接全量重写,代码简单得多。
8. iText5 老项目的兼容写法与迁移对照
存量系统里 iText5 的占比仍然很高,这套写法还是得会。
8.1 itext-asian 与 STSong-Light 的适用边界
iText5 处理中文有两条路。第一条是用 CJK 字体包里的STSong-Light:
BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); AcroFields form = stamper.getAcroFields(); form.setFieldProperty("name", "textfont", bf, null); form.setField("name", "张三");这条路依赖itext-asian这个单独的 jar 包,不引它直接报Font 'STSong-Light' with 'UniGB-UCS2-H' is not recognized。而且NOT_EMBEDDED意味着没嵌入,正是第 1 节说的那种会翻车的用法,只适合内部临时预览。
第二条路是加载真实字体文件并嵌入,这是我推荐的方式:
BaseFont bf = BaseFont.createFont("fonts/SourceHanSansSC-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);如果字体是.ttc集合,路径要写成fonts/xxx.ttc,0,逗号后面的数字是索引,漏写会直接抛异常。
iText5 有一个必须知道的顺序约束:setFieldProperty必须在setField之前调用,顺序反了字体会不生效,字段还是用默认的 Helvetica 渲染,中文变方块。这个坑我至少踩过两次,因为代码不会报错,只是结果不对。
压平在 iText5 里是stamper.setFormFlattening(true),注意它必须在stamper.close()之前设置,且设置之后就不能再改字段值了。
8.2 迁移到 7 的接口对照表
如果项目要升级,下面这张表能省不少翻文档的时间:
| 能力 | iText5 写法 | iText7 写法 |
|---|---|---|
| 打开文档 | new PdfReader(src)+PdfStamper | new PdfDocument(reader, writer, props) |
| 拿表单 | stamper.getAcroFields() | PdfAcroForm.getAcroForm(pdfDoc, true) |
| 设置文本 | form.setField(name, value) | ((PdfTextFormField)f).setValue(value) |
| 设置字体 | form.setFieldProperty(name, "textfont", bf, null) | field.setFont(pdfFont) |
| 复选框 | form.setField(name, "Yes") | ((PdfButtonFormField)f).setValue("Yes") |
| 压平 | stamper.setFormFlattening(true) | form.flattenFields() |
| 只读 | form.setFieldProperty(name, "setfflags", ...) | field.setReadOnly(true) |
迁移时最容易出问题的不是这些主流程 API,而是异常处理的行为差异。iText5 大量方法失败时返回布尔值或者静默忽略,iText7 直接抛PdfException的子类。以前能“跑完但结果不对”的代码,迁移后会变成“跑到一半就中断”,需要补一层容错逻辑,把单字段失败降级成日志告警而不是整体失败。
我在一个模拟项目里做过这种迁移,几百个字段的表单,迁移后第一版跑挂了十几次,全是原来被静默吞掉的脏数据(字段名带空格、选项值不匹配、某些字段被设成了只读)。某种程度上这是好事,问题暴露得早,比在生产上生成一批错文件强。
最后分享一个我自己一直在用的小技巧:给填充逻辑写一套回归校验,生成完成后重新打开输出文件,把每个字段的值读回来跟预期比对,不一致的直接报错。这套校验拦下过好几次“看起来填成功、实际值丢了”的隐性故障,尤其是在处理多行文本和特殊字符的时候,比人眼抽查靠谱得多。