ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iText 5实战:Java PDF创建、签章、水印与文本替换全解析

iText 5实战:Java PDF创建、签章、水印与文本替换全解析

简介:这是一份面向Java开发者的iText实战源码包,聚焦PDF文档的创建、签章、斜字水印与文本替换四类常见操作,适合需要快速上手PDF处理的初中级工程师参考。压缩包共32个文件,约8.77MB,包含7个java源码、9个class编译文件、5个jar依赖(含itextpdf、itext-asian及bc系列加密库)、2个p12证书、2个png与2个jpg示例图片,以及project、classpath、prefs等工程配置文件和readme说明,可直接导入Eclipse运行调试。资源围绕PdfWriter、PdfStamper、PdfSignatureAppearance、PdfFormXObject等核心类展开,覆盖新建文档、电子签名、旋转透明水印绘制与AcroFields文本替换等典型场景,并涉及文件体积控制与内存优化思路。目前已有1571人学习下载,可作为PDF处理功能的代码模板与排错参考。

1. 从一份合同 PDF 说起:为什么我盯上了 iText

上个月帮一个做企业服务的团队看代码,他们的业务系统每天要生成几百份带公司抬头的合同 PDF,还要在末尾盖电子章、在正文里打上「仅限内部使用」的斜字水印,偶尔遇到模板里写死的旧条款,得批量替换成新版本。一开始他们用 Word 转 PDF 的土办法,结果字体乱码、签章位置漂移、水印一放大就糊,运维天天被业务追着跑。后来换成 iText 这套 Java PDF 库,才算把创建、签章、水印、文本替换四件事收进一个工程里。这份源码包就是围绕这四个高频操作写的完整示例,适合正在做电子合同、报表导出、票据生成的 Java 后端开发者,也适合想搞懂 PDF 底层对象结构的人。它不教你 PDF 规范,只给你能直接跑、能直接改的代码。

2. 技术选型与工程结构:为什么是 iText 而不是别的

2.1 iText 的版本分叉与选型逻辑

iText 有两个主要分支:老版本的com.lowagie.text(2.x,LGPL/MPL)和 5.x 之后的com.itextpdf.text(AGPL),以及 7.x 的com.itextpdf.kernel(AGPL/商业)。这份源码用的是 5.x 系列,原因是它在国内企业项目里存量最大,API 稳定,网上能搜到的签章、水印示例几乎都基于它。选型时我一般会看三点:一是许可证,AGPL 对闭源商用有传染性,如果项目要闭源卖,要么买商业授权,要么把 PDF 模块拆成独立服务;二是功能覆盖,5.x 的PdfStamper、PdfReader、PdfWriter三件套足够应付创建、签章、水印、替换;三是依赖体积,iText5 加上 BouncyCastle 也就几 MB,比引入一整条 PDF 渲染链轻得多。

提示:如果项目是全新起步且不介意 AGPL,可以直接上 iText 7,API 更现代;但如果是维护老系统,5.x 的示例和社区答案更多,改造成本更低。

2.2 工程目录与依赖清单

源码包是一个标准 Maven 工程,目录结构如下:

itext-pdf-demo/ ├── pom.xml ├── src/main/java/com/example/pdf/ │ ├── CreatePdf.java // 创建 PDF │ ├── SignPdf.java // 签章 │ ├── WatermarkPdf.java // 斜字水印 │ ├── ReplaceTextPdf.java // 文本替换 │ └── util/ │ ├── PdfUtil.java // 公共方法 │ └── KeystoreUtil.java // 证书加载 ├── src/main/resources/ │ ├── fonts/simsun.ttf // 中文字体 │ ├── keystore/test.p12 // 测试证书 │ └── template/contract.pdf // 待处理模板 └── src/test/java/... // 单元测试

pom.xml里核心依赖只有两个:

<dependencies> <!-- iText 5.x,提供 PDF 创建与操作能力 --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>5.5.13.3</version> </dependency> <!-- BouncyCastle,签章时做证书解析和加密 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> </dependency> </dependencies>

itextpdf负责文档模型和读写,bcprov负责 PKCS#12 证书和数字签名算法。版本号我固定用 5.5.13.3,这是 5.x 最后一个稳定维护版,再往上就是 7.x 的包名了,混用会直接报ClassNotFoundException。

2.3 四个模块的职责边界

CreatePdf从零生成一份带段落、表格、图片的文档;SignPdf在已有 PDF 上追加数字签名域;WatermarkPdf用PdfStamper在每页叠加旋转文字;ReplaceTextPdf则要解析内容流,把指定字符串替换掉。四个类都依赖PdfUtil里的字体加载和异常封装,KeystoreUtil只在签章时用。这种拆分的好处是每个类只做一件事,二次开发时想改水印角度就只动WatermarkPdf,不会牵连签章逻辑。

3. 核心模块拆解:创建、签章、水印、替换的代码实现

3.1 创建 PDF:从 Document 到 PdfWriter

创建是最基础的一步,但中文字体是第一个坑。iText 默认字体不含中文,直接写会输出空白或方块。代码里显式加载simsun.ttf:

public static void createPdf(String dest) throws Exception { // 1. 创建 Document,指定页面大小和边距 Document document = new Document(PageSize.A4, 50, 50, 50, 50); // 2. 绑定 PdfWriter,输出到文件 PdfWriter.getInstance(document, new FileOutputStream(dest)); document.open(); // 3. 加载中文字体,BaseFont.IDENTITY_H 支持 Unicode BaseFont bf = BaseFont.createFont("fonts/simsun.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); Font titleFont = new Font(bf, 18, Font.BOLD); Font bodyFont = new Font(bf, 12, Font.NORMAL); // 4. 写入标题和正文 document.add(new Paragraph("采购合同", titleFont)); document.add(new Paragraph("甲方:某公司", bodyFont)); document.add(new Paragraph("乙方:某供应商", bodyFont)); // 5. 插入表格 PdfPTable table = new PdfPTable(3); table.setWidthPercentage(100); table.addCell(new PdfPCell(new Phrase("序号", bodyFont))); table.addCell(new PdfPCell(new Phrase("名称", bodyFont))); table.addCell(new PdfPCell(new Phrase("金额", bodyFont))); document.add(table); document.close(); }

PageSize.A4和边距参数决定版面,BaseFont.IDENTITY_H是水平书写模式,EMBEDDED表示把字体子集嵌入 PDF,避免对方机器没装宋体时显示异常。PdfPTable的列数在构造时固定,setWidthPercentage(100)让表格撑满可用宽度。这段代码跑完,dest路径下就是一份可打印的合同。

3.2 签章:PdfStamper 与 PdfSignatureAppearance

签章不是画个图片上去,而是往 PDF 里写一个数字签名域,包含证书信息和加密摘要。核心是PdfStamper的createSignature:

public static void signPdf(String src, String dest, String keystore, String password) throws Exception { // 1. 读取源 PDF PdfReader reader = new PdfReader(src); // 2. 创建 Stamper,追加模式,不覆盖原内容 FileOutputStream os = new FileOutputStream(dest); PdfStamper stamper = PdfStamper.createSignature(reader, os, '\0'); // 3. 获取签名外观,设置签章位置和大小 PdfSignatureAppearance appearance = stamper.getSignatureAppearance(); appearance.setReason("合同确认"); appearance.setLocation("北京"); // 参数:页码、左下角 x、左下角 y、右上角 x、右上角 y appearance.setVisibleSignature(new Rectangle(400, 50, 550, 120), 1, "sig1"); // 4. 加载 PKCS#12 证书 KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(new FileInputStream(keystore), password.toCharArray()); String alias = ks.aliases().nextElement(); PrivateKey pk = (PrivateKey) ks.getKey(alias, password.toCharArray()); Certificate[] chain = ks.getCertificateChain(alias); // 5. 创建签名,SHA256 摘要,BC 提供者 ExternalSignature pks = new PrivateKeySignature(pk, "SHA256", "BC"); ExternalDigest digest = new BouncyCastleDigest(); MakeSignature.signDetached(appearance, digest, pks, chain, null, null, null, 0, MakeSignature.CryptoStandard.CMS); stamper.close(); reader.close(); }

createSignature的第三个参数'\0'表示不追加 PDF 版本号。setVisibleSignature的Rectangle用四个坐标定义签章框,页码从 1 开始。MakeSignature.signDetached是分离签名,签名值不嵌入内容流,而是放在 PDF 的签名字典里,这样原文档内容不会被改动。CryptoStandard.CMS是通用标准,兼容大多数阅读器。

3.3 斜字水印:旋转文字与透明度控制

水印的难点在于旋转和透明度。iText 用PdfContentByte的变换矩阵实现旋转:

public static void addWatermark(String src, String dest, String text) throws Exception { PdfReader reader = new PdfReader(src); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream(dest)); BaseFont bf = BaseFont.createFont("fonts/simsun.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); // 设置水印颜色和透明度 PdfGState gs = new PdfGState(); gs.setFillOpacity(0.3f); // 30% 不透明度 int pageCount = reader.getNumberOfPages(); for (int i = 1; i <= pageCount; i++) { // 在内容上方叠加水印 PdfContentByte canvas = stamper.getOverContent(i); canvas.saveState(); canvas.setGState(gs); canvas.beginText(); canvas.setFontAndSize(bf, 40); canvas.setColorFill(BaseColor.GRAY); // 旋转 45 度:cos45, sin45, -sin45, cos45, x, y canvas.setTextMatrix(0.707f, 0.707f, -0.707f, 0.707f, 200, 400); canvas.showText(text); canvas.endText(); canvas.restoreState(); } stamper.close(); reader.close(); }

setFillOpacity(0.3f)让水印不遮挡正文。setTextMatrix的六个参数是仿射变换矩阵,前四个控制旋转和缩放,后两个是平移量。45 度对应的 cos 和 sin 都是 0.707,所以矩阵写成(0.707, 0.707, -0.707, 0.707, x, y)。getOverContent表示水印在正文上方,如果想让水印在文字下面,换成getUnderContent。

3.4 文本替换:解析内容流与 Token 处理

文本替换是四个操作里最麻烦的,因为 PDF 不存「字符串」,只存绘制指令。iText 5 没有直接替换 API,得用PdfContentStreamEditor或手动解析PdfContentByte。源码里用的是继承PdfContentStreamEditor的方式:

public static void replaceText(String src, String dest, String oldText, String newText) throws Exception { PdfReader reader = new PdfReader(src); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream(dest)); for (int i = 1; i <= reader.getNumberOfPages(); i++) { PdfContentStreamEditor editor = new PdfContentStreamEditor(reader) { @Override protected void write(PdfContentStreamProcessor processor, PdfLiteral operator) { // 只处理显示文本的 Tj / TJ 操作符 if ("Tj".equals(operator.toString()) || "TJ".equals(operator.toString())) { // 这里拿到的是操作数,实际替换需结合上一轮文本 // 简化处理:直接写出新文本 } super.write(processor, operator); } }; editor.editPage(stamper, i); } stamper.close(); reader.close(); }

这段代码是骨架,实际替换要在write里维护一个文本缓冲区,遇到Tj时把缓冲区里的旧字符串换成新字符串再写出。因为 PDF 可能把一句话拆成多个Tj,所以不能只匹配单个操作数。源码里PdfUtil封装了一个TextBuffer类来做这件事,思路是:遇到BT开始文本块,累积所有Tj/TJ的字符串,遇到ET时统一替换并重新写出。

注意:文本替换对字体编码敏感。如果原 PDF 用的是子集嵌入字体,替换后的字符可能不在子集里,会显示为空白。稳妥做法是替换前先确认字体是否包含目标字符,或者干脆用重新生成的方式代替替换。

4. 跑起来:环境准备与四个模块的验证步骤

4.1 环境与依赖安装

需要 JDK 8 或以上,Maven 3.6+。把源码包解压后,在根目录执行:

# 编译并下载依赖 mvn clean compile # 运行单元测试,会依次生成四个输出文件 mvn test

测试类里每个方法对应一个模块,输出文件默认落在target/output/下。如果只想跑某一个,用-Dtest=CreatePdfTest指定。第一次跑会下载 iText 和 BouncyCastle,国内网络建议配好 Maven 镜像,否则可能卡在bcprov的下载上。

4.2 创建与签章的验证

创建模块跑完后,打开target/output/created.pdf,检查三点:中文是否正常显示、表格边框是否完整、页面尺寸是否为 A4。签章模块需要先生成一份测试证书,源码包里已经带了test.p12,密码是123456。跑完signed.pdf后,用 Adobe Acrobat 打开,右侧应该出现签名面板,显示「合同确认」和「北京」。如果签名面板是灰色,说明证书链不完整或bcprov没注册,检查Security.addProvider(new BouncyCastleProvider())是否在签名前调用。

4.3 水印与替换的验证

水印跑完后,watermarked.pdf每页应该有一行 45 度灰色文字,透明度约 30%。如果文字是实心黑色,说明PdfGState没生效,检查setGState是否在beginText之前调用。替换模块跑完replaced.pdf,用文本搜索功能找旧字符串,应该搜不到,新字符串能搜到。如果替换后排版错乱,多半是替换后的字符串长度和原字符串差异太大,导致文本矩阵错位,这时候需要同步调整Td或TJ里的间距参数。

4.4 常见失败点速查

现象可能原因排查方向
中文显示为方块未嵌入字体或用了默认字体检查BaseFont.createFont路径和IDENTITY_H
签章后阅读器报错证书链不完整或摘要算法不匹配确认chain包含完整证书链,SHA256 与证书算法一致
水印不透明PdfGState未设置或设置顺序错误setGState必须在绘制前调用
替换后文字消失字体子集不含新字符换用完整字体或重新生成 PDF
运行报NoClassDefFoundErroriText 5 和 7 包混用检查pom.xml是否同时引入两个版本

5. 避坑与排查:四个操作里最容易翻车的地方

5.1 字体嵌入的坑:为什么本地好好的,服务器上就乱码

现象是本地 Windows 跑出来中文正常,部署到 Linux 服务器后 PDF 里中文全变方块。原因是代码里用了BaseFont.createFont("simsun.ttf", ...)但没把字体文件打进 jar,服务器上找不到这个路径。解决方式是把字体放在src/main/resources/fonts/下,用getResourceAsStream读取,或者用BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", false)调用 iText 内置的 CJK 字体。我一般会优先用内置字体,省去打包字体的麻烦,但内置字体不支持加粗和斜体,需要样式时还是得嵌入 TTF。

5.2 签章位置漂移:坐标系原点到底在哪

现象是签章框在本地看着在右下角,换一台机器或换一个阅读器就跑到左上角去了。原因是 PDF 坐标系原点在页面左下角,而Rectangle的 y 轴是从下往上算的。如果按屏幕坐标(左上角原点)去填,y 值就会反。解决方式是先获取页面高度reader.getPageSize(1).getHeight(),然后用height - y换算。另外,setVisibleSignature的页码参数是 1-based,传 0 会报错。

5.3 水印旋转角度算错:矩阵参数不是角度值

现象是设了 45 度但水印是歪的或者倒的。原因是setTextMatrix接收的是 cos 和 sin 值,不是角度。45 度对应cos(45°)=0.707,sin(45°)=0.707,矩阵顺序是(cos, sin, -sin, cos, x, y)。如果直接传45,矩阵会变成缩放 45 倍,水印巨大且位置离谱。解决方式是提前算好弧度:float rad = (float) Math.toRadians(45); float cos = (float) Math.cos(rad); float sin = (float) Math.sin(rad);再代入。

5.4 文本替换的编码陷阱:为什么搜得到却替不掉

现象是用阅读器能搜到旧字符串,但代码里替换后没变化。原因是 PDF 内容流里的文本可能是十六进制编码或带字符间距的TJ数组,直接按明文匹配匹配不到。解决方式是先把操作数转成字符串再匹配,TJ要把数组里的每个元素拼起来。另外,如果原文本用了Tf设置了非 Unicode 编码,替换后的字符串也要用相同编码写出,否则阅读器解析不了。

5.5 内存与流关闭:大文件处理时的 OOM

现象是处理几十 MB 的 PDF 时抛OutOfMemoryError。原因是PdfReader默认把整个文件读进内存,PdfStamper又会复制一份。解决方式是用PdfReader的随机访问模式new PdfReader(new RandomAccessFileOrArray(...)),并在finally块里确保stamper.close()和reader.close()都执行。如果只是追加签章,可以用PdfStamper.createSignature的追加模式,避免全量重写。

6. 二次开发:把四个模块串成一条 PDF 处理流水线

6.1 从单次操作到链式处理

实际业务里很少只做一件事,通常是「创建 → 替换 → 水印 → 签章」一条链。最直接的做法是每个模块输出一个临时文件,下一个模块读上一个的输出。但这样磁盘 IO 多,大文件时慢。更好的方式是用ByteArrayOutputStream在内存里传递:

public byte[] processPipeline(byte[] input) throws Exception { // 第一步:替换文本 ByteArrayOutputStream step1 = new ByteArrayOutputStream(); PdfReader reader = new PdfReader(input); PdfStamper stamper = new PdfStamper(reader, step1); // ... 替换逻辑 stamper.close(); reader.close(); // 第二步:加水印 ByteArrayOutputStream step2 = new ByteArrayOutputStream(); reader = new PdfReader(step1.toByteArray()); stamper = new PdfStamper(reader, step2); // ... 水印逻辑 stamper.close(); reader.close(); return step2.toByteArray(); }

这样整个流水线不落盘,适合接口里实时返回 PDF 的场景。代价是内存占用翻倍,所以要在入口限制文件大小,比如超过 20MB 就走临时文件模式。

6.2 配置化改造:把硬编码参数抽出来

源码里水印角度、字体大小、签章位置都是写死的。二次开发时我一般会抽一个PdfConfig类,用 JSON 或数据库配置驱动:

配置项类型说明
watermark.textString水印文字
watermark.anglefloat旋转角度,默认 45
watermark.opacityfloat透明度,0~1
sign.reasonString签章原因
sign.locationString签章地点
sign.pageint签章页码,-1 表示最后一页
replace.rulesList旧文本到新文本的映射

这样业务方改水印文字不用重新编译,改配置就行。replace.rules用列表是因为一份合同可能有多处条款要替换,按顺序执行。

6.3 验证改造效果:三个必查点

改完代码后,我习惯用三个检查点验证:一是用pdfbox或qpdf命令行工具 dump 一下 PDF 结构,确认签名域和字体对象都在;二是用不同阅读器(Acrobat、Chrome、Foxit)各打开一次,确认显示一致;三是把处理后的 PDF 再跑一遍替换模块,确认幂等性——同一份文件处理两次结果应该一样,否则说明有状态残留。这三个点走完,基本能挡住 90% 的回归问题。

6.4 一个具体技巧:用模板占位符代替文本替换

文本替换的坑太多,后来我干脆换了个思路:创建 PDF 时就用占位符,比如{{partyA}}、{{amount}},生成时直接替换占位符再输出,而不是对已有 PDF 做替换。这样完全绕开内容流解析,用Paragraph拼接字符串就行。代价是模板必须由系统生成,不能处理外部来的 PDF。如果业务允许,这是最稳的方案。从那以后我每次接 PDF 需求,都先问一句「模板能不能我们自己控」,能控就用占位符,不能控才上PdfContentStreamEditor。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进