☰
Java iText实战:PDF创建、签章、水印与文本替换全解析
2026/11/6 3:54:45 网站建设 项目流程

简介:这份源码工具包面向需要处理 PDF 文档的 Java 开发者,聚焦 iText 库的典型应用场景,涵盖新建 PDF 文档、添加电子签章、插入斜字水印以及替换文本内容等常见需求,适合具备一定 Java 基础、希望快速上手 PDF 编程的中级开发者参考实践。资源包共 32 个文件,包含 7 个 java 源文件、9 个编译后的 class 文件、5 个 jar 依赖包,以及 png、jpg 示例图片和 p12 证书文件等,压缩包整体约 8.77MB,源码与依赖齐全,可直接导入工程运行调试。目前已有 1573 人学习下载。借助其中的示例代码与证书素材,读者能够对照理解 PdfWriter、PdfStamper、PdfSignatureAppearance 等核心类的调用方式,掌握签章位置设置、水印透明度与旋转角度控制、表单文本替换等关键实现思路,并留意文件体积增长与内存占用等实际问题,为项目中的 PDF 处理任务提供可复用的参考。

1. 从一份“改不动”的合同 PDF 说起

上周帮一个做供应链系统的朋友看代码,他遇到个很典型的需求:客户发来一份几十页的采购合同 PDF,要求在指定位置盖上公司电子章,整页铺一层斜着的“内部资料”水印,还要把正文里几处旧的收款账户号替换成新的。他一开始想用前端库在浏览器里改,结果字体一嵌就乱、印章位置一缩放就飘,最后绕回服务端用 Java 处理。这类“PDF 创建 + 签章 + 斜字水印 + 文本替换”的组合需求,在合同、发票、报表、证明文件场景里几乎躲不掉,而 iText 正好是 Java 生态里干这活最顺手的工具之一。这份源码把四个高频动作拆成了可运行的示例,适合手上正被 PDF 折腾、想直接抄一段能跑代码的后端开发者,也适合想搞清楚 PDF 坐标体系和字体嵌入到底怎么回事的人。

2. 技术选型与工程结构:为什么是 iText,四个能力怎么摆

2.1 选 iText 而不是别的库,理由很实际

Java 里能操作 PDF 的库不算少,PDFBox 偏底层、拼装页面元素时写得比较啰嗦,而 iText 的 API 层次更贴近“我要往页面上放一段文字、一张图、一个印章”这种直觉。这份源码用的就是 iText 的核心能力:PdfWriter负责从零生成文档,PdfReader+PdfStamper负责在已有文档上叠加内容,PdfContentStreamEditor(或早期版本的PdfContentByte重写思路)负责碰正文里的文本。四个示例分别对应创建、签章、水印、替换,正好覆盖了“从无到有”和“在已有上改”两条主线。

需要提前说清楚一个版本差异,这是很多人第一次跑就翻车的地方:iText 5 和 iText 7 的包名、类名完全不同。iText 5 是com.itextpdf.text.*,iText 7 拆成了com.itextpdf.kernel.*、com.itextpdf.layout.*等模块。这份源码的写法更接近 iText 5 的风格(Document、PdfStamper这套),如果你本地拉的是 iText 7 的依赖,编译会直接报找不到类。常见做法是看pom.xml里锁的版本,跟着源码走,别自己升级。

2.2 目录结构长什么样

工程是标准的 Maven 结构,按能力分包,读起来不费劲:

pdf-itext-demo/ ├── pom.xml ├── src/main/java/ │ ├── create/ │ │ └── PdfCreateDemo.java # 从零创建 PDF │ ├── sign/ │ │ └── PdfSignDemo.java # 图片印章叠加 │ ├── watermark/ │ │ └── PdfWatermarkDemo.java # 斜字水印 │ └── replace/ │ └── PdfReplaceDemo.java # 文本替换 ├── src/main/resources/ │ ├── fonts/simhei.ttf # 中文字体 │ ├── images/seal.png # 印章图片 │ └── template/contract.pdf # 待处理模板 └── output/ # 生成结果落盘目录

四个包各自独立,互不依赖,好处是你想只抄水印那段,直接把watermark包拎走就行,不用被其他示例的依赖拖住。resources下放字体、印章图、模板 PDF,这是 PDF 处理项目的通用做法——中文字体必须显式嵌入,否则生成的 PDF 在别人机器上打开就是一堆方块。

2.3 依赖与运行入口

pom.xml里核心就一个 iText 依赖,加上日志和测试。跑之前确认 JDK 版本(源码按 JDK 8 写的,JDK 11/17 也能跑,但要注意模块化对反射的影响)。每个 Demo 类都有main方法,改一下输入输出路径就能单独执行:

# 编译 mvn clean compile # 单独跑创建示例 mvn exec:java -Dexec.mainClass="create.PdfCreateDemo"

如果不想用 exec 插件,直接在 IDE 里右键main方法运行也一样。输出统一落到output/目录,跑完去那里看结果,比在控制台猜有没有成功靠谱得多。

3. 创建与签章:从空白文档到盖章落位

3.1 创建 PDF:字体、页面、段落三件事

创建示例的核心是把“中文字体嵌入”这件事做对。iText 默认的Font不带中文,直接写中文会抛异常或输出乱码,所以必须先注册字体:

// 注册中文字体,IDENTITY_H 保证中文按 Unicode 正确编码 BaseFont baseFont = BaseFont.createFont( "src/main/resources/fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); Font cnFont = new Font(baseFont, 12); Document document = new Document(PageSize.A4, 50, 50, 60, 60); PdfWriter.getInstance(document, new FileOutputStream("output/created.pdf")); document.open(); // 标题用大字号,正文用 12 号 document.add(new Paragraph("采购合同", new Font(baseFont, 18, Font.BOLD))); document.add(new Paragraph("本合同由甲乙双方于约定日期签订……", cnFont)); document.close();

BaseFont.IDENTITY_H是关键参数,它告诉 iText 用横向书写、按 Unicode 映射字形,中文、日文、韩文都靠它。BaseFont.EMBEDDED表示把字体子集嵌进 PDF,文件会大一点,但换台机器打开不会掉字。Document构造里的四个数字是左、右、上、下边距,单位是磅(1 磅约等于 1/72 英寸),A4 纸宽 595 磅,减掉左右各 50 就是正文可用宽度。段落会自动换行,但表格和图片不会,后面签章定位就吃这个亏。

3.2 签章:坐标原点在左下角,不是左上角

签章示例用PdfStamper在已有 PDF 上盖图片章。最容易错的是坐标——PDF 的坐标原点在页面左下角,Y 轴向上,而前端和图片处理习惯是左上角原点、Y 轴向下。直接拿设计稿上的像素坐标往里填,章会跑到页面外或者上下颠倒。

PdfReader reader = new PdfReader("src/main/resources/template/contract.pdf"); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream("output/signed.pdf")); int pageNum = 1; // 盖章页码,从 1 开始 PdfContentByte canvas = stamper.getOverContent(pageNum); // 盖在内容之上 Image seal = Image.getInstance("src/main/resources/images/seal.png"); // 目标:章的中心落在页面右下区域 float pageWidth = reader.getPageSize(pageNum).getWidth(); float pageHeight = reader.getPageSize(pageNum).getHeight(); float sealSize = 120f; float centerX = pageWidth - 150; float centerY = 120; // setAbsolutePosition 传的是左下角坐标,所以要减半个尺寸 seal.scaleAbsolute(sealSize, sealSize); seal.setAbsolutePosition(centerX - sealSize / 2, centerY - sealSize / 2); canvas.addImage(seal); stamper.close(); reader.close();

getOverContent和getUnderContent的区别值得记一下:前者盖在原有文字上面,适合印章、签名;后者垫在下面,适合背景水印。setAbsolutePosition接收的是图片左下角坐标,所以想让章的中心对准某个点,得把宽高各减一半。scaleAbsolute按目标尺寸缩放,别用scaleToFit去猜,印章这种有固定视觉尺寸的元素,直接给死值最稳。如果章是透明背景的 PNG,iText 会保留 alpha 通道,盖上去不会出现白底方块。

3.3 多页盖章与骑缝章的思路

单页盖章上面那段就够了,但合同往往是多页,常见需求是每页右下角盖一个,或者做骑缝章。多页就是循环for (int i = 1; i <= reader.getNumberOfPages(); i++),每页取一次getOverContent(i)再addImage。骑缝章稍微麻烦点,它要求章被“切”到多页边缘,做法是把一张章图按页数等分,每页只画对应的一条,并且位置要贴着页面右边缘。这个示例没做骑缝,但思路是通的:canvas.clipToStroke配合saveState/restoreState控制绘制区域,属于进阶用法,二次开发时按需加。

提示:盖章前先确认模板 PDF 没有被加密。PdfReader遇到带权限密码的文件会抛BadPasswordException,需要先用new PdfReader(path, passwordBytes)打开,再判断是否允许修改。

4. 斜字水印与文本替换:两个最容易踩坑的模块

4.1 斜字水印:旋转、透明度、平铺三要素

水印示例要的是整页斜着铺满的“内部资料”字样。核心是PdfContentByte的变换矩阵:先平移到目标点,再旋转,再写文字。很多人只写一次showTextAligned就以为完事,结果只有一行字,不是铺满。

PdfReader reader = new PdfReader("output/created.pdf"); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream("output/watermarked.pdf")); BaseFont wmFont = BaseFont.createFont("src/main/resources/fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); for (int i = 1; i <= reader.getNumberOfPages(); i++) { PdfContentByte canvas = stamper.getUnderContent(i); // 水印垫在文字下方 canvas.saveState(); canvas.setGState(new PdfGState().setFillOpacity(0.15f)); // 透明度 15% canvas.beginText(); canvas.setFontAndSize(wmFont, 40); canvas.setColorFill(BaseColor.GRAY); // 按网格平铺,每行每列错开 for (float x = 60; x < reader.getPageSize(i).getWidth(); x += 220) { for (float y = 60; y < reader.getPageSize(i).getHeight(); y += 180) { canvas.showTextAligned(Element.ALIGN_CENTER, "内部资料", x, y, 45); // 45 度 } } canvas.endText(); canvas.restoreState(); } stamper.close();

setFillOpacity(0.15f)控制透明度,值太低看不见、太高盖住正文,0.1 到 0.2 之间比较合适。showTextAligned的最后一个参数是旋转角度,45 度是斜字水印的常见选择。双层循环的步长决定密度,x += 220、y += 180是经验值,页面小就调小,页面大就调大。saveState/restoreState成对出现,保证透明度和颜色不会污染后续绘制——这个不写,后面再画别的东西会继承水印的灰色和透明度,属于典型的“玄学 bug”。

4.2 文本替换:为什么不能简单 replace

文本替换是四个示例里最容易被低估的。直觉上读 PDF 文本、String.replace、再写回去就行,但 PDF 里的文字不是按段落存的,而是按“绘制指令”存的,一段话可能被拆成多个Tj/TJ操作,中间还夹着字体切换和定位。直接替换字符串,轻则替换不到,重则把内容流结构搞坏。

这份源码的做法是基于内容流编辑:用PdfReader拿到页面内容,解析出文本操作符,匹配目标字符串后替换操作数,再写回。iText 5 里常用PdfContentStreamEditor(部分版本叫PdfContentStreamProcessor的子类)来干这事:

// 自定义内容流编辑器,只处理文本绘制操作 class ReplaceEditor extends PdfContentStreamEditor { private final String target; private final String replacement; ReplaceEditor(String target, String replacement) { this.target = target; this.replacement = replacement; } @Override protected void write(PdfContentStreamProcessor processor, PdfLiteral operator, List<PdfObject> operands) { // 只关心 Tj / TJ 这类文本操作符 if ("Tj".equals(operator.toString()) && !operands.isEmpty()) { PdfString text = (PdfString) operands.get(0); String value = text.toUnicodeString(); if (value.contains(target)) { operands.set(0, new PdfString(value.replace(target, replacement))); } } super.write(processor, operator, operands); } }

调用侧把编辑器挂到每一页上:

PdfReader reader = new PdfReader("src/main/resources/template/contract.pdf"); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream("output/replaced.pdf")); ReplaceEditor editor = new ReplaceEditor("旧账户号", "新账户号"); for (int i = 1; i <= reader.getNumberOfPages(); i++) { editor.processContent(reader.getPageContent(i), reader.getPageResources(i)); stamper.getOverContent(i).setLiteral(editor.getResultantContent()); // 写回 } stamper.close();

这里有几个现实约束必须说清楚。第一,替换后的字符串长度如果和原文差太多,可能撑破原有排版,因为 PDF 里每个字的位置是算好的,替换不会自动重排。第二,如果目标文字跨了多个Tj操作(比如被字体切换打断),单操作符匹配会漏掉,需要做跨操作符的拼接匹配,复杂度上一个台阶。第三,中文文本在内容流里可能是十六进制编码,toUnicodeString()能还原,但前提是字体带了 ToUnicode 映射,扫描件或某些生成工具产出的 PDF 没有这个映射,替换会失效。所以这个示例更适合“自己生成的、结构规整的 PDF”,拿它去改别人扫描的合同,大概率要失望。

4.3 四个模块的输入输出对照

把四个示例的输入输出理一遍,跑的时候心里有数:

模块输入输出关键类
创建无(代码内构造内容)output/created.pdfDocument、PdfWriter
签章template/contract.pdf + seal.pngoutput/signed.pdfPdfStamper、Image
水印created.pdfoutput/watermarked.pdfPdfContentByte、PdfGState
替换template/contract.pdfoutput/replaced.pdfPdfContentStreamEditor

注意签章和替换都读template/contract.pdf,水印读的是创建示例的产物,所以跑的顺序建议是:先创建,再水印,签章和替换各自独立跑。顺序乱了不会报错,但你会对着一个空文件纳闷半天。

5. 避坑与排查:五个真实会卡住你的问题

5.1 中文全是方块或问号

现象:生成的 PDF 打开后中文显示为方块、问号,或者直接抛Font 'simhei' with 'Identity-H' is not recognized。原因基本是字体没嵌入,或者用了BaseFont.HELVETICA这类内置字体去写中文。解决:确认BaseFont.createFont的第三个参数是BaseFont.EMBEDDED,第二个参数是BaseFont.IDENTITY_H,并且字体文件路径真实存在。用 Maven 打包后路径会变,src/main/resources下的文件在 jar 里是 classpath 资源,得用getResourceAsStream读,不能再用文件路径。

5.2 印章位置偏移或上下颠倒

现象:章盖到了页面外,或者位置和预期差一个页面高度。原因:把左上角原点的坐标直接填进了 PDF 的左下角坐标系。解决:所有 Y 坐标用pageHeight - 设计稿Y换算,并且记住setAbsolutePosition传的是图片左下角。调试时先在目标点画一个红色小方块确认坐标,再放章图,比反复改数字快。

5.3 水印盖住了正文文字

现象:水印颜色太深,正文看不清。原因:用了getOverContent且透明度没设或设得太高。解决:水印一律用getUnderContent,透明度控制在 0.1 到 0.2,颜色用浅灰。如果模板本身有背景色,getUnderContent可能被背景盖住,这时才考虑getOverContent配更低透明度。

5.4 文本替换没生效也不报错

现象:代码跑完,输出文件里旧文字还在。原因有三种:目标文字跨了多个文本操作符;PDF 没有 ToUnicode 映射;替换的是TJ数组里的元素而代码只处理了Tj。解决:先用PdfTextExtractor把页面文本抽出来,确认目标字符串在抽取结果里是连续的;再打印内容流看实际操作符类型;跨操作符的情况需要把相邻操作数拼起来匹配,工作量不小,评估清楚再动手。

5.5 处理大文件时内存溢出

现象:几十 MB 的 PDF 处理到一半抛OutOfMemoryError。原因:PdfReader默认把整个文件读进内存,加上PdfStamper的中间缓冲,内存翻倍。解决:启动参数加-Xmx调大堆,或者用PdfReader的随机访问模式(new PdfReader(RandomAccessFileOrArray))减少常驻内存;批量处理时一页一页 flush,别把所有 stamper 攒着一起关。

注意:iText 5 是 AGPL 协议,商用闭源项目需要购买商业授权。二次开发前先确认授权方式,这是比任何技术坑都更该先搞清楚的事。

6. 二次开发:把四个示例拼成一条可复用的处理链

四个独立示例真正有价值的地方,是它们能拼成一条流水线。我一般会抽一个PdfProcessor接口,把“创建、盖章、水印、替换”做成可组合的步骤,每个步骤接收InputStream返回InputStream,这样既能单独调用,也能串起来:

public interface PdfStep { byte[] apply(byte[] input) throws Exception; } // 组合执行,前一步的输出是后一步的输入 public byte[] pipeline(byte[] source, List<PdfStep> steps) throws Exception { byte[] current = source; for (PdfStep step : steps) { current = step.apply(current); } return current; }

这么改的好处是,业务侧只需要声明“先替换账户号,再盖骑缝章,最后铺水印”,顺序可调、步骤可插拔,测试时也能单独验证某一步。验证方法很朴素:每一步产出后立刻用PdfTextExtractor抽文本比对关键字段,再人工打开看一眼版面,别等整条链跑完才发现中间某步把字体搞丢了。

还有一个具体技巧值得说:印章和水印的坐标、透明度、字体大小这些参数,别硬编码在类里,抽到application.yml或常量类里。我见过一个项目把章的位置写死在代码里,换了个模板尺寸,章全飘了,改一次要重新发版。参数外置之后,运维改配置就能适配新模板,省下的返工时间远超当初抽参数的那半小时。

最后留一句血泪经验:PDF 处理这类活,最怕“看起来跑通了”。文件能生成、能打开,不代表字体嵌对了、坐标算准了、替换真的生效了。从那以后我每次改完这类代码,都强制走一遍“抽取文本比对 + 换台机器打开 + 打印页面尺寸”三件套,宁可多花五分钟,也别让一份盖错位置的合同流到客户手里。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询