简介:围绕 iText 库处理 PDF 的完整 Java 工程示例,面向有一定 Java 基础、需在业务系统中实现 PDF 生成与二次编辑的开发者。内容覆盖新建文档、电子签章、斜字水印插入与文本替换四条主线,并配套 BouncyCastle 加密组件与亚洲字体支持,可在 Eclipse 中直接导入调试。压缩包共 32 个文件、约 8.77MB:7 个 java 源文件与 9 个 class 构成可执行示例,5 个 jar 提供 iText 5.5.11 及 bcprov、bcpkix 等依赖,2 个 p12 证书与 png、jpg 素材用于签章与水印效果验证,另有 project、classpath 等工程配置,目录按 src、lib、bin 分层。已有 1572 人学习下载。读者可据此理解 PdfWriter、PdfStamper、PdfSignatureAppearance 等核心 API 的调用方式,掌握签名位置设定、水印旋转与透明度调节、页面文本遍历替换等细节,并借鉴文件体积控制与内存占用的处理思路,减少自行摸索成本。
1. 从一百份合同的批量修改说起
上个月一个做采购系统的同学拿着一叠合同 PDF 来找我,说要在两百多份同类文件里统一加水印、贴电子签章,还要把模板里几个占位符换掉。他试过几个现成的图形工具,一份文件点十几次鼠标,改到三十份就崩溃了。后来我给了一份用 iText 操作 PDF 文档的源码工程,创建、签章、斜字水印、文本替换四个动作各一个类,命令行传参跑批,两百份两分钟跑完。这条链路每一步都有具体的问题:中文字体没嵌入,打印出来是方块;签完章再动一个字,整份签名立刻失效;水印画在内容层下面,被页面白底盖得干干净净;文本替换碰上办公软件导出的 PDF,字符串死活匹配不上。下面把这份源码按功能拆开讲,讲到能直接抄去改的程度。
2. 选型与创建骨架:从依赖坐标到第一份带中文的 PDF
2.1 iText 5 和 iText 7 的 API 差在哪,先选一个
同一个库,五代和七代几乎是两套东西。包名换了、Document 的语义变了、签章从 PdfStamper 挪到了 PdfSigner。选错了版本,网上搜到的示例代码大概率编译不过——这是新手最常卡住的第一关。
| 能力 | iText 5(com.itextpdf.text/com.itextpdf.text.pdf) | iText 7(com.itextpdf.kernel/com.itextpdf.layout) |
|---|---|---|
| 新建文档 | Document+PdfWriter | PdfDocument+Document(layout 层) |
| 内容写入 | document.add(Element) | document.add(IBlockElement) |
| 底层画布 | PdfContentByte | PdfCanvas |
| 签章 | PdfStamper.createSignature+MakeSignature | PdfSigner+PdfSignatureAppearance |
| 改内容流 | PdfContentStreamEditor(5.5.x 起) | PdfCanvasProcessor+ 自定义IEventListener |
| 亚洲字体包 | 需要额外的itext-asian | 已内建,直接PdfFontFactory.createFont |
判断标准很简单:老系统里已经有com.itextpdf.text.Document的,继续留在 5.x,改动成本最低;新起项目直接上 7.x,布局引擎和字体处理确实更稳。要提醒一句,这两代的社区版都是 AGPL 授权,商业闭源分发前把授权路径评估清楚,别等上线了才发现问题。
依赖坐标按当前稳定版填,下面用属性占位,实际写死版本号时去中央仓库查最新的稳定版即可:
<properties> <itext.version>5.x.x</itext.version> <!-- 填当前稳定版 --> <bc.version>1.x.x</bc.version> </properties> <dependencies> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>${itext.version}</version> </dependency> <!-- 签章依赖:BC 提供加密算法与证书链处理 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>${bc.version}</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcpkix-jdk15on</artifactId> <version>${bc.version}</version> </dependency> </dependencies>itextpdf是核心包,画布、签章、解析器都在里面;BC 的两个包只在签章环节用到,bcpkix负责 CMS 容器和证书链,缺了它签章那步会在运行期抛ClassNotFoundException,而不是编译期报错,这个隐形依赖要留意。
2.2 第一份文档:Document、Writer 和关闭顺序
创建这一段是整个工程的地基,后面水印和签章都靠它产出的样本文件练手。最小可跑的结构是这样:
public class PdfCreator { public static void create(String outPath) throws Exception { // 页边距:左、右 56pt,上、下 72pt,单位是 pt(1pt = 1/72 英寸) Document doc = new Document(PageSize.A4, 56, 56, 72, 72); // 第二个参数是输出流,第三个是页面事件,后面画页眉页脚要用 PdfWriter writer = PdfWriter.getInstance(doc, new FileOutputStream(outPath)); writer.setPageEvent(new HeaderFooterEvent()); doc.open(); doc.add(new Paragraph("工业设备采购合同", titleFont())); doc.add(new Paragraph("合同编号:HT-2024-0001", bodyFont())); doc.add(Chunk.NEWLINE); doc.add(buildItemTable()); doc.close(); // 内部会 flush 并关闭底层的 PdfWriter } private static Font titleFont() throws Exception { // 字体文件随工程打包,路径相对 classpath 或绝对路径都可 BaseFont bf = BaseFont.createFont("fonts/NotoSansSC-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); return new Font(bf, 18f, Font.BOLD); } private static Font bodyFont() throws Exception { BaseFont bf = BaseFont.createFont("fonts/NotoSansSC-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); return new Font(bf, 10.5f); } }逻辑上有三处值得说明。PageSize.A4展开是 595×842 pt,后面的坐标计算都以这个为准,不要凭感觉写数字。BaseFont.IDENTITY_H是横向书写的复合字体编码,中文必须走这个模式,用默认的Cp1252写中文只会得到乱码或者空洞。BaseFont.EMBEDDED表示把字体子集嵌入 PDF 文件,这一步决定文件拿到别人机器上、拿到打印店会不会变形——不嵌入的后果是阅读器拿本地字体顶替,字形宽度一变,排版全乱。
doc.close()不要包在 try 的最外层草草了事。正确姿势是open()之后所有操作放在 try 里,finally 里判空再 close。如果中途抛异常,Document没有正常关闭,输出流里会留下一个结构残缺的 PDF,阅读器打开直接提示文件损坏,排查时很容易误判成库的问题。
2.3 中文字体与页面坐标系
坐标系是这块最容易翻车的地方。PDF 原点在页面左下角,x 向右增长,y 向上增长。这和绝大多数前端、Android 的左上角原点相反。我在项目里见过同事按屏幕坐标写y = 100,结果签章框贴在页面顶部,怎么调参数都不对。
写一个mm换算方法能省很多事:
// 1 毫米 = 72 / 25.4 pt,约 2.8346 public static float mm(double v) { return (float) (v * 72.0 / 25.4); } // 页面上距顶部 30mm 处画一条基线,A4 高 842pt float y = PageSize.A4.getHeight() - mm(30);字体文件也不要硬编码绝对路径。我一般把字体放到src/main/resources/fonts/下,用Thread.currentThread().getContextClassLoader().getResourceAsStream(...)读成字节数组,再交给BaseFont.createFont(String name, byte[] ttfAfm, byte[] pfb, boolean cached)这个重载。这样打成 jar 之后仍然能工作,直接写"D:/fonts/xxx.ttf"的代码上线必炸。
字体缓存也得注意。BaseFont.createFont默认带缓存,同一路径重复调用不会重复解析;但如果你每次请求都换一个不同路径的临时文件,缓存键就失效,内存会一路上涨。批量任务的正确做法是在类初始化时创建一次BaseFont,全流程复用。
提示:
ttc是字体集合,路径要写成"fonts/xxx.ttc,0",逗号后面的数字是子字体索引,索引错了会抛IndexOutOfBoundsException或者拿到一个缺字的字体。
2.4 表格与页眉页脚事件
合同类文档离不开表格。PdfPTable的构造函数接收一个float[]列宽数组,数值是相对比例,不是绝对宽度:
private static PdfPTable buildItemTable() throws Exception { // 四列,比例 1:3:1.5:2,最终宽度等于可用页宽 PdfPTable table = new PdfPTable(new float[]{1f, 3f, 1.5f, 2f}); table.setWidthPercentage(100); // 占满内容区宽度 table.setHeaderRows(1); // 第 1 行作为表头,跨页自动重复 table.setSplitLate(false); // 行允许拆到下一页,避免大片留白 String[] headers = {"序号", "设备名称", "数量", "单价(元)"}; for (String h : headers) { PdfPCell cell = new PdfPCell(new Phrase(h, bodyFont())); cell.setHorizontalAlignment(Element.ALIGN_CENTER); cell.setBackgroundColor(new BaseColor(238, 241, 245)); cell.setPadding(6f); table.addCell(cell); } // ... 数据行按同样方式 addCell return table; }setHeaderRows(1)是跨页表格的关键,缺了它第二页开始就没有表头,长文档直接不可读。setSplitLate(false)控制的是拆分时机:默认情况下 iText 宁可把整行推到下一页,也不愿意拆开一行,遇到行高较大的表格会在页底留出大片空白;关掉之后行内允许断开,版面利用率明显提升,代价是偶发的半行内容,按业务取舍。
页眉页脚靠PdfPageEventHelper实现,在onEndPagePhase阶段用writer.getDirectContent()拿到画布直接写:
public class HeaderFooterEvent extends PdfPageEventHelper { private BaseFont bf; @Override public void onOpenDocument(PdfWriter writer, Document document) { try { bf = BaseFont.createFont("fonts/NotoSansSC-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); } catch (Exception e) { throw new RuntimeException("页眉字体加载失败", e); } } @Override public void onEndPage(PdfWriter writer, Document document) { PdfContentByte cb = writer.getDirectContent(); cb.saveState(); cb.setFontAndSize(bf, 8); cb.setColorFill(BaseColor.GRAY); // 页眉:距顶 40pt 居中 float top = document.getPageSize().getTop() - 40; ColumnText.showTextAligned(cb, Element.ALIGN_CENTER, new Phrase("内部资料 · 请勿外传"), 298, top, 0); // 页脚页码:当前页 / 总页数,总页数在关闭文档前才有值 String pageNo = String.format("第 %d 页", writer.getPageNumber()); ColumnText.showTextAligned(cb, Element.ALIGN_RIGHT, new Phrase(pageNo), document.getPageSize().getRight() - 56, document.getPageSize().getBottom() + 30, 0); cb.restoreState(); } }getDirectContent()拿到的是覆盖在内容之上的画布,页眉页脚用它正好;restoreState()一定要配对,否则字体、颜色这些图形状态会泄漏到下一页。总页数"共 X 页"这种格式没法在onEndPage里拿到,常见做法是在onCloseDocument里回填,或者用一个两遍渲染的方案,第一遍统计页数再生成第二遍。
3. 电子签章:证书、外观和那 4 个字节
3.1 签章到底改了 PDF 的什么
很多人以为签章就是往页面上贴一张签名图片,其实图片只是外观(appearance),真正的签名写在文件的/AcroForm字典里,是一段 CMS/PKCS#7 结构。PDF 规范规定,签名覆盖的字节范围记录在/ByteRange数组里,形如[0, 长度1, 偏移2, 长度2],中间挖掉的那段正是存放签名值的位置,大小在签名前就要预留好。
这解释了三个现象。第一,签完之后文件体积会变大一点,因为签名值是增量写入的,所以叫增量更新(incremental update),不重写原有内容。第二,签名后任何一次内容修改都会让摘要对不上,阅读器立刻报「文档已被修改」。第三,预估签名空间给得太小,会直接抛NotEnoughSpace类异常,签不进去。
所以工程上的铁律是:签章必须是整条流水线的最后一步。加水印、改文本、填表单全部干完,最后再签。
3.2 生成密钥库和签名代码
自签证书用 JDK 自带的 keytool 就够,别一上来就折腾 CA 签发:
# 生成 PKCS12 格式密钥库,有效期 10 年 keytool -genkeypair \ -alias signer \ -keyalg RSA -keysize 2048 -sigalg SHA256withRSA \ -validity 3650 \ -dname "CN=Internal Signer, OU=IT, O=Demo Org, C=CN" \ -keystore keystore.p12 -storetype PKCS12 \ -storepass changeit -keypass changeit-dname里的字段会显示在阅读器的签名面板上,CN是名称,别乱填;-validity 3650是十年,有效期过短的证书会让已经签好的文件在未来某天突然显示「证书已过期」,这是血泪经验。生产环境当然要用正规 CA 签发的证书,自签证书在阅读器里会显示「签名有效性未知」,但摘要校验照样能过,实测阶段够用。
签名部分:
public class PdfSignerSupport { public static void sign(String src, String dest, String ksPath, char[] ksPwd, String tsaUrl) throws Exception { BouncyCastleProvider provider = new BouncyCastleProvider(); Security.addProvider(provider); // 只需注册一次,重复注册无副作用 KeyStore ks = KeyStore.getInstance("PKCS12"); try (InputStream in = new FileInputStream(ksPath)) { ks.load(in, ksPwd); } String alias = ks.aliases().nextElement(); PrivateKey pk = (PrivateKey) ks.getKey(alias, ksPwd); Certificate[] chain = ks.getCertificateChain(alias); // 必须带完整链 PdfReader reader = new PdfReader(src); // 第 3 个参数是 PDF 版本字符,'\0' 表示沿用原版本 // 第 4 个参数是临时目录,增量更新时需要 // 第 5 个参数 true 表示追加模式,保留原作者签名 PdfStamper stamper = PdfStamper.createSignature(reader, new FileOutputStream(dest), '\0', new File("tmp"), true); PdfSignatureAppearance appearance = stamper.getSignatureAppearance(); appearance.setReason("合同审批通过"); appearance.setLocation("内部审批系统"); appearance.setVisibleSignature(new Rectangle(360, 120, 540, 200), 1, "sig1"); ExternalSignature es = new PrivateKeySignature(pk, "SHA256", "BC"); ExternalDigest digest = new BouncyCastleDigest(); TSAClient tsa = (tsaUrl == null || tsaUrl.isEmpty()) ? null : new TSAClientBouncyCastle(tsaUrl); MakeSignature.signDetached(appearance, digest, es, chain, null, null, tsa, 0, MakeSignature.CryptoStandard.CADES); stamper.close(); reader.close(); } }几个参数逐一交代。chain必须是完整证书链,只放叶子证书的话,阅读器校验时会提示「签名者身份无法验证」——很多自签场景的报错都出在这里。PrivateKeySignature的第三个参数"BC"指定加密提供者,注册了 BouncyCastle 才能用 SHA256 这类算法。estimatedSize传 0 让库自己估算,如果要接时间戳(TSA),估算值会偏大,别为了省字节手动压。CryptoStandard.CADES是 PAdES 基线用的标准,跟CMS的区别在于多了签名属性,长期验证需要它。
src到dest必须是两个文件,别想着原地覆盖。PdfStamper打开源文件时持有随机访问句柄,同一时刻写同一个路径,结果就是文件被清空。
3.3 外观、坐标和多签章
setVisibleSignature的四个参数是矩形的左下角和右上角坐标,最后一个参数是签名域的名字,全文档唯一。这里有两个隐蔽的坐标系问题。
一是页面旋转。同一份 PDF,/Rotate 90之后,getOverContent用的坐标仍以 MediaBox 为准,而用户看到的页面已经转过去了。用new Rectangle(...)直接给坐标会出现签名框跑到页边之外的情况。稳妥的写法是先读reader.getPageRotation(page),为 90 或 270 时把宽高对调再计算。
二是 CropBox 和 MediaBox 不一致。扫描件常用 CropBox 裁掉黑边,如果签章框坐标落在 CropBox 之外,用户在阅读器里根本看不到签名外观,但签名本身是有效的,这种「签了但看不见」的问题排查起来极其费神。用reader.getCropBox(page)取实际可见区域再算坐标。
多签章场景直接调多次sign,每次都读上一次的输出作为输入,因为追加模式会保留之前的签名字典,所以两个章可以共存,阅读器会列出两条签名记录。
3.4 时间戳与长期验证
时间戳的作用是证明「这个签名在这个时刻就已经存在」。没有它,证书过期后签名就只能靠签名时间字段自证,可信度不足。接 TSA 只要一个 HTTP 地址,TSAClientBouncyCastle会把签名摘要发过去,拿回一个时间戳令牌塞进签名属性里。地址通常由内部证书服务提供,填占位地址:
TSAClient tsa = new TSAClientBouncyCastle("http://tsa.example.internal/tsa", "user", "pass");长期验证(LTV)要求把证书链、CRL、OCSP 响应一起嵌进文档的 DSS 字典,这样即使签发机构若干年后下线,验证方仍能靠文档自带的材料完成校验。iText 5 里用LtvVerification在签章之后追加:
PdfReader r = new PdfReader(signedPdf); PdfStamper st = new PdfStamper(r, new FileOutputStream(ltvPdf)); LtvVerification v = st.getLtvVerification(); AcroFields af = st.getAcroFields(); for (String name : af.getSignatureNames()) { v.addVerification(name, null, null, LtvVerification.CertificateOption.SIGNING_CERTIFICATE, LtvVerification.Level.OCSP_CRL, LtvVerification.CertificateInclusion.YES); } v.merge(); st.close();注意这一步本身也是增量更新,必须在所有签名完成之后做。如果签完章又调了LtvVerification,再想加第三个章就得小心处理,版本链会变复杂。我一般把「签章 → 补 DSS」当成一个原子步骤,中间不插其他操作。
4. 斜字水印与文本替换:内容流里的两件危险动作
4.1 用 PdfContentByte 画一道 45 度的水印
水印的本质是往页面内容流里插一段文本绘制指令,跟签章完全是两个层面的事。最简单的斜字水印:
public static void addWatermark(String src, String dest, String text, float opacity, float rotation) throws Exception { PdfReader reader = new PdfReader(src); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream(dest)); BaseFont bf = BaseFont.createFont("fonts/NotoSansSC-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); int total = reader.getNumberOfPages(); for (int i = 1; i <= total; i++) { // CropBox 才是用户实际看到的区域,用它算中心点最稳 Rectangle box = reader.getCropBox(i); float cx = (box.getLeft() + box.getRight()) / 2; float cy = (box.getBottom() + box.getTop()) / 2; PdfContentByte cb = stamper.getOverContent(i); cb.saveState(); // 透明度单独作用于填充色,0.15 左右既能看见又不挡正文 cb.setGState(new PdfGState().setFillOpacity(opacity)); cb.setColorFill(BaseColor.GRAY); cb.setFontAndSize(bf, 48); // 旋转 45 度居中绘制,rotation 单位是度,逆时针为正 ColumnText.showTextAligned(cb, Element.ALIGN_CENTER, new Phrase(text), cx, cy, rotation); cb.restoreState(); } stamper.close(); reader.close(); }getOverContent和getUnderContent的区别决定了水印的生死。前者在水印画在正文之上,后者在正文之下。用 Under 的话,如果原页面有一层白色背景矩形(办公软件导出时很常见),水印会被彻底盖住,页面看起来什么都没有。所以除非你确认底层内容无遮挡,否则一律用 Over,配合低透明度,观感上不会压住文字。
showTextAligned内部用的是文本矩阵变换,中文走IDENTITY_H完全没问题。如果换成手动setTextMatrix(cos, sin, -sin, cos, x, y),注意顺序是先平移再旋转还是先旋转再平移,写反了水印会飞出页面。
4.2 平铺水印、透明度和层级
单条斜字在很多页面上显得太空,全页平铺更常见:
// 在页面上按网格平铺水印,步长按页面宽高均分 int cols = 3, rows = 4; float stepX = box.getWidth() / cols; float stepY = box.getHeight() / rows; cb.beginText(); cb.setFontAndSize(bf, 28); cb.setColorFill(new BaseColor(160, 160, 160)); cb.setGState(new PdfGState().setFillOpacity(0.12f)); for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { float x = box.getLeft() + stepX * c + stepX / 2; float y = box.getBottom() + stepY * r + stepY / 2; // 用矩阵直接摆位,比 showTextAligned 少了 ALIGN 相关的居中计算 double rad = Math.toRadians(45); cb.setTextMatrix((float) Math.cos(rad), (float) Math.sin(rad), (float) -Math.sin(rad), (float) Math.cos(rad), x, y); cb.showText(text); } } cb.endText();beginText/endText之间不能混入其他操作符,setGState这类图形状态指令放在外面,否则某些阅读器会直接忽略。透明度设置要作用在填充色上,setStrokeOpacity对文字无效,这也是常被搞混的点。
性能上,平铺水印的绘制指令数随页数线性增长。两百页的文件每页 12 条水印,指令量不大,但如果顺手加了阴影或者描边,文件体积会明显膨胀。我一般只保留纯填充,避免使用setTextRenderMode的描边模式,某些老版本阅读器对描边中文的渲染会出现锯齿和错位。
4.3 文本替换的三条路径,先想清楚边界
文本替换是这四个功能里最容易许愿、最容易失望的。PDF 不是流式文档,它保存的是「在某个坐标用什么字体画什么字形」,字符顺序、断行、连字全都丢了。常见的三条路径:
| 路径 | 做法 | 适用场景 | 主要风险 |
|---|---|---|---|
| 覆盖重绘 | 提取文本定位后,用白底矩形盖住原文字,再写新文字 | 表单、固定位置的单行字段 | 底色不纯时会露出白块 |
| 内容流编辑 | 解析内容流,替换 Tj/TJ 里的字符串 | 简单字体、单行完整字符串 | 文本被拆成多段时匹配不上 |
| 重新生成 | 用数据源 + 模板重新出一份 PDF | 有原始数据、格式固定 | 需要能拿到源头数据 |
第一条路听起来最土,实际项目里用得最多。定位靠LocationTextExtractionStrategy拿到每段文字和它的矩形区域,然后用一个带背景色的矩形覆盖,再用相同的字体和字号写新内容。只要页面底色是纯白,效果和真替换没区别。底色是浅灰、带纹理或者渐变的时候,白块会很明显,这时候就得取邻近像素做背景色采样,成本立刻上去了。
第二条路干净,但对 PDF 生成来源极其敏感。
4.4 内容流编辑器做精确替换
iText 5.5.x 提供了PdfContentStreamEditor,能拦到每个操作符,适合做条件替换:
public class TextReplaceEditor extends PdfContentStreamEditor { private final Map<String, String> rules; public TextReplaceEditor(Map<String, String> rules) { this.rules = rules; } @Override protected void write(PdfContentStreamProcessor processor, PdfLiteral operator, List<PdfObject> operands) throws IOException { String op = operator.toString(); if ("Tj".equals(op) || "'".equals(op) || "\"".equals(op)) { // 单字符串操作符,直接替换第 1 个操作数 for (int i = 0; i < operands.size(); i++) { if (operands.get(i) instanceof PdfString) { operands.set(i, doReplace((PdfString) operands.get(i))); } } } else if ("TJ".equals(op) && !operands.isEmpty() && operands.get(0) instanceof PdfArray) { // 数组操作符,可能被拆成多段,逐段替换 PdfArray arr = (PdfArray) operands.get(0); for (int i = 0; i < arr.size(); i++) { if (arr.getPdfObject(i) instanceof PdfString) { arr.set(i, doReplace((PdfString) arr.getPdfObject(i))); } } } super.write(processor, operator, operands); // 原样写回,保证其他指令不丢 } private PdfString doReplace(PdfString src) { String text = src.toUnicodeString(); for (Map.Entry<String, String> e : rules.entrySet()) { text = text.replace(e.getKey(), e.getValue()); } // 原文档是简单字体时用 TEXT_PDFDOCENCODING,复合字体用 TEXT_UNICODE return new PdfString(text, PdfObject.TEXT_UNICODE); } }调用侧:
PdfReader reader = new PdfReader(src); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream(dest)); TextReplaceEditor editor = new TextReplaceEditor(rules); for (int i = 1; i <= reader.getNumberOfPages(); i++) { // 换到本页内容流,编辑结果写回本页 editor.processContent(reader.getPageContent(i), stamper.getOverContent(i)); } stamper.close(); reader.close();write方法的泛型参数在不同小版本间可能是ArrayList<PdfObject>,编译报错时按 IDE 的提示改一下签名即可,逻辑不用动。processContent的第二个参数是关键:用getOverContent会把替换后的内容画在上层,原文字还在底下露着;正确做法是用getUnderContent或者干脆用stamper.getOverContent之后再涂白。这一点务必自己跑一遍看效果,别信任何「应该没问题」。
替换失败最常见的两个原因:一是文本被拆成了多个TJ片段(排版引擎为了调整字距经常会拆),比如「合同编号」可能被拆成「合同」和「编号」两段,你的完整字符串永远匹配不上,得先做片段聚合或者改用正则;二是原 PDF 用的是自定义编码或子集字体,toUnicodeString()返回的是空串或者乱码,这种情况只能退回到覆盖重绘方案。
5. 避坑与排查:五个现场记录
5.1 水印加了但页面看不到
现象:文件生成成功,打开后干干净净,用文本提取还能看到水印字符串。原因大概率是用了getUnderContent,而页面最底层有一块覆盖全页的白色背景矩形。解决:切换到getOverContent并把透明度压到 0.1~0.2;如果确实必须在下层,先用解析器把白色填充矩形去掉或者改色。判断方法很简单,把透明度改成 1、字号改成 200,如果还是看不见,基本可以确认是被盖住了。
5.2 中文水印显示成方块或乱码
现象:页面上的水印是一排方块,或者干脆一片空白。原因有两个:字体用了HELVETICA这类不含中日韩字形的内置字体;或者编码参数写成了BaseFont.WINANSI。解决:统一走BaseFont.IDENTITY_H加BaseFont.EMBEDDED,并且确认字体文件本身完整。另外BaseFont.createFont抛异常时如果被 catch 后吞掉,代码会用默认字体继续跑,结果就是静默输出方块,所以这个异常必须往外抛。
5.3 签完章阅读器提示「文档已被修改」
现象:签名面板里签名存在,但立刻显示红色警示,摘要校验失败。原因:签章之后又调用了PdfStamper做了别的事,哪怕是加一行注释,也会破坏/ByteRange覆盖范围。解决:把签章固化到流程末尾;如果业务要求「先签后补内容」,那就在补内容后重新签一次,形成两个签章,而不是让第一个签名失效。另外要注意PdfStamper打开时如果没带append=true,整个文件会被重写,之前所有签名全部报废。
5.4 文本替换能匹配到一部分,另一部分始终不动
现象:某些字段替换成功,某些无论如何都匹配不上,提取文本看明明一模一样。原因:一是字距调整把字符串拆成了多个TJ元素;二是原文里存在不可见字符(零宽空格、软连字符);三是全角和半角混用。解决:替换前先对提取文本做规范化,去掉\u200B、\u00AD这类字符,并把全角数字统一成半角再比对;对TJ数组做跨元素拼接后再匹配,匹配成功时按元素边界回填。
5.5 批量跑两百份就内存溢出或者句柄耗尽
现象:跑到一百多份时抛OutOfMemoryError,或者报「文件被另一进程占用」。原因:PdfReader和PdfStamper在异常路径上没有关闭,底层持有的文件句柄没有释放;或者把所有文件都读进ByteArrayOutputStream再集中写盘,堆被撑爆。解决:每个文件的处理包在 try-with-resources 里,输出先写临时文件再原子重命名;批量任务加并发上限,我一般控制在 CPU 核数的三分之一,PDF 处理是 IO 和 CPU 混合型,开太大反而拖慢。
6. 验证与进阶:把「看着对」变成「读出来对」
跑通不等于做对。这四个功能里有三个是肉眼不可验证的——签名是否有效、水印是否真的画进了每一页、替换后的文本能不能被检索到,都得靠代码回读来断言。
签章的验证我固定写成一个方法:
public static void verify(String pdf) throws Exception { PdfReader reader = new PdfReader(pdf); AcroFields af = reader.getAcroFields(); for (String name : af.getSignatureNames()) { PdfPKCS7 pkcs7 = af.verifySignature(name); boolean cryptoOk = pkcs7.verify(); // 摘要与签名值是否匹配 boolean wholeDoc = af.signatureCoversWholeDocument(name); // 是否覆盖全文 int revision = af.getRevision(name); int total = af.getTotalRevisions(); System.out.printf("域=%s 摘要=%s 全文覆盖=%s 版本=%d/%d 签名时间=%s%n", name, cryptoOk, wholeDoc, revision, total, pkcs7.getSignDate().getTime()); } reader.close(); }verify()返回 true 只代表签名值和文档摘要对得上,不代表证书受信。要判断证书链,得自己拿pkcs7.getCertificates()去和受信根比对,或者接 OCSP 查询;自签证书这里永远是「未受信」,别把它当成 bug 去查。
水印的验证走文本提取,把每一页的文本用PdfTextExtractor拉出来,断言包含水印关键字,页数等于总页数。这一步能抓到「某些页被跳过」的低级错误。替换的验证同理,替换后重新提取文本,检查旧字符串出现次数为 0、新字符串出现次数符合预期。
设备上的签字盖章最后是要拿去执行的,我吃过一次亏:测试环境里所有文件都签得好好的,到了生产才发现那批扫描件用了 CropBox,签章框整个落在可见区域之外,客户打印出来只看到一片空白。从那以后我每次上线前都强制走一遍「回读验证 + 抽三份转成图片人眼看」的双重检查,别嫌麻烦,这比事后解释省事得多。希望帮到你。
本文还有配套的精品资源,点击获取