简介:这是一份面向Java开发者的iText操作PDF实战源码包,聚焦创建文档、电子签章、斜字水印与文本替换四类常见需求,适合需要快速落地PDF处理功能的中初级开发者参考。包内共32个文件,以7个java源码与9个class编译文件为核心,配合itextpdf、itext-asian、bcprov等5个jar依赖,以及p12证书、png/jpg示例图片和Eclipse工程配置,压缩包约8.77MB,可直接导入IDE运行调试。资源围绕PdfWriter、PdfStamper、PdfSignatureAppearance、PdfFormXObject等关键类展开,覆盖从新建页面、设置签名证书到绘制旋转半透明水印、遍历AcroFields替换文本的完整链路,并附readme说明。目前已有1571人学习下载,读者可借此掌握PDF签章与水印的实现细节,理解文本替换对文档结构的影响,并参考依赖版本与工程结构快速搭建自己的PDF处理模块。
1. 从一份“改不动”的合同 PDF 说起:这个工具到底能干什么
上个月帮一个做企业服务的团队看代码,他们有个需求听起来特别朴素:把系统里生成的合同 PDF 加上公司公章图片,再在页脚打一行“仅限内部使用”的斜字水印,最后把模板里写死的甲方名称替换成真实客户名。结果三个人折腾了一周,公章位置对不上、水印盖住了正文、替换文本后字体全乱。问题不在业务逻辑,而在他们用错了 PDF 处理的路子——拿文本编辑器思维去改二进制文档,翻车是必然的。
这份源码包解决的正是这类场景:用 iText 在 Java 里对 PDF 做四件事——从零创建文档、给已有文档加签章、铺斜字水印、做文本内容替换。它适合两类人:一类是刚接触 PDF 编程、需要一份能跑通的参考实现的新手;另一类是在做电子合同、报表导出、票据系统的开发者,想看看生产级代码怎么组织这些操作。关键词就俩:源码、工具,但这两个词背后是一整套 PDF 对象模型的实操。
2. 技术选型与工程骨架:为什么是 iText,代码怎么摆
2.1 iText 版本选择与依赖引入
这个项目用的是 iText 5.x 系列,原因很实际:5.x 的 API 稳定、中文资料多、签章相关的PdfSignatureAppearance类成熟度高。iText 7 虽然架构更现代,但签名和文本替换的写法差异大,对新手不够友好。如果你的项目已经在用 7.x,核心思路相通,但类名和包路径要换。
Maven 依赖只需要两个核心包,注意itextpdf和itext-asian要配套,后者负责中文字体,缺了它中文会变成空白方块:
<dependencies> <!-- PDF 核心操作库 --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>5.5.13</version> </dependency> <!-- 亚洲字体支持,处理中文必须 --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itext-asian</artifactId> <version>5.2.0</version> </dependency> </dependencies>参数说明:itextpdf的 5.5.13 是 5.x 末期的稳定版,修了不少早期签名相关的 bug;itext-asian提供STSong-Light等中文字体,通过BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED)调用。常见做法是把字体文件直接放进resources目录,用BaseFont.createFont("fonts/simsun.ttc,0", ...)加载,这样不依赖系统字体,部署到服务器上不会因为缺字体而翻车。
2.2 目录结构与模块划分
源码包的目录结构是典型的 Maven 单模块工程,按功能而不是按类型分包,这点值得借鉴:
pdf-toolkit/ ├── src/main/java/com/example/pdf/ │ ├── create/ # 创建类:PdfCreator │ ├── sign/ # 签章类:PdfSigner │ ├── watermark/ # 水印类:PdfWatermarker │ ├── replace/ # 替换类:PdfTextReplacer │ └── util/ # 工具类:FontUtil、PdfUtil ├── src/main/resources/ │ ├── fonts/ # 中文字体文件 │ └── templates/ # PDF 模板 └── src/test/java/ # 单元测试为什么按功能分包而不是按controller/service/dao分?因为 PDF 操作是工具型代码,每个功能相对独立,按功能分包能让调用方一眼找到入口。util包里放字体加载和 PDF 读取的公共方法,避免每个类里重复写PdfReader的初始化逻辑。
2.3 核心工具类的初始化逻辑
所有操作都绕不开PdfReader和PdfWriter这一对。读已有文档用PdfReader,写新文档用PdfWriter,两者通过PdfStamper桥接。工具类里我一般会封装一个安全关闭的方法,因为PdfReader不关会导致文件句柄泄漏,在批量处理时尤其明显:
public class PdfUtil { /** * 安全关闭 PdfReader,避免文件句柄泄漏 * @param reader 待关闭的读取器,允许为 null */ public static void closeQuietly(PdfReader reader) { if (reader != null) { try { reader.close(); } catch (Exception e) { // 关闭失败不抛异常,记录日志即可 System.err.println("关闭 PdfReader 失败: " + e.getMessage()); } } } /** * 加载中文字体,优先用 resources 下的字体文件 * @return BaseFont 实例 */ public static BaseFont loadChineseFont() throws IOException, DocumentException { // 常见做法:把字体文件放 resources/fonts 下,用相对路径加载 String fontPath = PdfUtil.class.getClassLoader() .getResource("fonts/simsun.ttc").getPath(); // ",0" 表示取 ttc 集合中的第一个字体 return BaseFont.createFont(fontPath + ",0", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); } }逻辑说明:closeQuietly吞掉关闭异常是有意为之,因为关闭失败通常不影响主流程,但如果不捕获,批量处理时一个失败会中断整个循环。loadChineseFont里BaseFont.IDENTITY_H是横向书写模式,BaseFont.EMBEDDED表示把字体嵌入 PDF,这样在没装该字体的机器上也能正常显示。参数",0"是 ttc 字体集合的索引,simsun.ttc 里第一个是宋体,第二个是黑体,写错索引字体会变样。
3. 四个核心功能拆解:创建、签章、水印、替换的代码实现
3.1 从零创建 PDF 并写入中文段落
创建文档是最基础的操作,但中文写入有个坑:直接用FontFactory.getFont拿不到中文字体,必须用BaseFont构造Font对象。下面这段代码创建一个带标题和正文的 PDF:
public class PdfCreator { public void createSimplePdf(String outputPath) throws Exception { // 输出流,true 表示覆盖已有文件 FileOutputStream fos = new FileOutputStream(outputPath); Document document = new Document(PageSize.A4, 50, 50, 60, 60); PdfWriter.getInstance(document, fos); document.open(); // 加载中文字体,不嵌入的话服务器上可能显示方块 BaseFont bf = PdfUtil.loadChineseFont(); Font titleFont = new Font(bf, 18, Font.BOLD); Font bodyFont = new Font(bf, 12, Font.NORMAL); // 标题段落,居中 Paragraph title = new Paragraph("项目验收报告", titleFont); title.setAlignment(Element.ALIGN_CENTER); document.add(title); // 正文段落,首行缩进两个字符 Paragraph body = new Paragraph("本报告用于记录项目各阶段的验收结果。", bodyFont); body.setFirstLineIndent(24); // 12pt 字体缩进 24pt 约等于两个字符 document.add(body); document.close(); fos.close(); } }参数说明:PageSize.A4是页面尺寸,后面四个参数是左、右、上、下边距,单位是点(1 点约等于 1/72 英寸)。setFirstLineIndent(24)里的 24 是点数,对应 12pt 字体的两个字符宽度。注意document.close()必须在fos.close()之前调用,否则 PDF 尾部结构写不完整,文件会打不开。
3.2 给已有 PDF 加签章:定位与图片插入
签章的本质是把公章图片插到 PDF 指定位置。难点在坐标系:PDF 的原点在左下角,而图片插入的坐标也是左下角,但人的直觉是从左上角算。下面代码把公章盖在最后一页右下角:
public class PdfSigner { public void signPdf(String srcPdf, String stampImage, String outputPdf) throws Exception { PdfReader reader = new PdfReader(srcPdf); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream(outputPdf)); // 获取最后一页 int pageNum = reader.getNumberOfPages(); PdfContentByte over = stamper.getOverContent(pageNum); // 读取公章图片 Image stamp = Image.getInstance(stampImage); // 缩放公章到 120x120 点 stamp.scaleAbsolute(120, 120); // 页面尺寸,用于计算右下角坐标 Rectangle pageSize = reader.getPageSize(pageNum); // 距离右边 50 点,距离下边 80 点 float x = pageSize.getWidth() - 120 - 50; float y = 80; stamp.setAbsolutePosition(x, y); over.addImage(stamp); stamper.close(); PdfUtil.closeQuietly(reader); } }逻辑说明:getOverContent表示在页面内容之上绘制,公章会盖住正文;如果想让正文盖住公章,用getUnderContent。坐标计算里pageSize.getWidth()是页面宽度,减去公章宽度和右边距得到 x 坐标。常见做法是把公章位置做成配置项,因为不同模板的留白区域不一样,写死坐标换个模板就翻车。
3.3 斜字水印:旋转角度与透明度控制
水印要斜着铺满页面,核心是PdfGState控制透明度,配合showTextAligned的旋转参数。下面代码在每页中央打一个 45 度倾斜的灰色水印:
public class PdfWatermarker { public void addWatermark(String srcPdf, String outputPdf, String watermarkText) throws Exception { PdfReader reader = new PdfReader(srcPdf); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream(outputPdf)); BaseFont bf = PdfUtil.loadChineseFont(); // 水印字体 40pt,颜色浅灰 Font font = new Font(bf, 40, Font.NORMAL, new BaseColor(200, 200, 200)); // 透明度设置:0.3 表示 30% 不透明 PdfGState gs = new PdfGState(); gs.setFillOpacity(0.3f); int totalPages = reader.getNumberOfPages(); for (int i = 1; i <= totalPages; i++) { PdfContentByte content = stamper.getOverContent(i); content.saveState(); content.setGState(gs); // 在页面中心绘制,旋转 45 度 Rectangle pageSize = reader.getPageSize(i); content.beginText(); content.setFontAndSize(bf, 40); content.setColorFill(new BaseColor(200, 200, 200)); // 参数:旋转角度、x、y、对齐方式、文本 content.showTextAligned(Element.ALIGN_CENTER, watermarkText, pageSize.getWidth() / 2, pageSize.getHeight() / 2, 45); content.endText(); content.restoreState(); } stamper.close(); PdfUtil.closeQuietly(reader); } }参数说明:setFillOpacity(0.3f)控制填充透明度,值越小水印越淡,0.3 是既能看清又不遮挡正文的常用值。showTextAligned的最后一个参数 45 是旋转角度,单位是度,正数逆时针旋转。saveState和restoreState成对出现,避免透明度设置污染后续绘制操作。注意水印文字如果太长,会超出页面边界,常见做法是根据页面宽度动态调整字号。
3.4 文本替换:为什么不能直接改,以及正确做法
PDF 里的文本不是按字符串存储的,而是按字符定位绘制的,所以没有“查找替换”这种直接操作。这个项目的做法是用PdfContentStreamEditor拦截内容流,在绘制指令层面替换。下面是一个简化实现:
public class PdfTextReplacer { public void replaceText(String srcPdf, String outputPdf, String oldText, String newText) throws Exception { PdfReader reader = new PdfReader(srcPdf); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream(outputPdf)); int totalPages = reader.getNumberOfPages(); for (int i = 1; i <= totalPages; i++) { // 获取页面内容字节流 byte[] pageContent = reader.getPageContent(i); PdfContentStreamEditor editor = new PdfContentStreamEditor() { @Override protected void write(PdfContentStreamProcessor processor, PdfLiteral operator) throws IOException { // 拦截文本绘制指令,做字符串替换 if ("Tj".equals(operator.toString())) { // 实际项目中这里需要解析操作数并替换 // 简化处理:直接替换字节流中的文本 } super.write(processor, operator); } }; // 重新写入页面内容 PdfDictionary pageDict = reader.getPageN(i); editor.editPage(stamper, pageDict); } stamper.close(); PdfUtil.closeQuietly(reader); } }逻辑说明:PDF 内容流是一系列操作符和操作数,文本绘制用Tj或TJ操作符。替换的难点在于文本可能被拆成多个片段,且字体编码可能不是 Unicode。常见做法是先用PdfTextExtractor提取文本确认内容,再针对特定模板做精确替换,而不是做通用替换。如果替换后字体不对,说明新文本的字体和原文本不一致,需要显式设置字体。
4. 避坑与排查:四个真实踩过的坑
4.1 中文显示为方块或乱码
现象:生成的 PDF 里中文全是方块,或者显示成问号。原因:没有嵌入中文字体,或者用了BaseFont.WINANSI编码。解决:用BaseFont.IDENTITY_H编码,并且设置BaseFont.EMBEDDED把字体嵌入文档。如果用的是FontFactory.getFont("SimSun"),在没装该字体的服务器上会失败,改成从resources加载字体文件。
4.2 签章位置偏移
现象:本地测试公章位置正确,部署到服务器后位置偏了。原因:不同 PDF 模板的页面尺寸和旋转角度不同,getPageSize返回的是未旋转的尺寸。解决:用reader.getPageSizeWithRotation(pageNum)获取实际显示尺寸,并且把公章坐标做成相对于页面宽高的比例值,而不是绝对点数。
4.3 批量处理时文件句柄泄漏
现象:处理几百个 PDF 后程序报“Too many open files”。原因:PdfReader没有关闭,或者PdfStamper关闭时抛异常导致reader没关。解决:用try-finally确保PdfReader一定关闭,或者用PdfUtil.closeQuietly封装。注意PdfStamper.close()会自动关闭关联的PdfReader,但异常情况下不会。
4.4 文本替换后字体错乱
现象:替换后的文本字体和周围不一致,或者字符间距异常。原因:PDF 内容流里的文本绘制指令绑定了字体资源,直接替换字符串不会更新字体引用。解决:替换时同时设置字体和字号,用setFontAndSize显式指定,并且确保新文本的字符编码和原字体兼容。如果原文本用了子集嵌入字体,替换的字符可能不在子集里,需要换用完整字体。
5. 二次开发与改造:从工具类到业务系统的落地技巧
拿到这份源码后,直接跑通四个功能不难,难的是把它接进真实业务系统。我一般会做三件事:抽接口、加配置、补日志。
先说抽接口。源码里的PdfCreator、PdfSigner这些类都是具体实现,业务代码直接依赖它们会导致换 PDF 库时改不动。常见做法是定义一个PdfService接口,把创建、签章、水印、替换四个方法抽象出来,iText 实现作为一个ItextPdfServiceImpl。这样以后想换 Apache PDFBox,只需要加一个新实现类。
public interface PdfService { // 创建 PDF,返回文件路径 String createPdf(PdfCreateRequest request); // 签章,支持多页签章 void signPdf(String srcPath, String stampPath, String outputPath); // 加水印 void addWatermark(String srcPath, String outputPath, String text); // 文本替换 void replaceText(String srcPath, String outputPath, String oldText, String newText); }再说加配置。公章位置、水印透明度、字体路径这些参数,不要写死在代码里。我一般会用一个pdf-config.yml管理,用 Spring 的@ConfigurationProperties注入。比如水印透明度,测试环境要 0.5 看得清,生产环境要 0.2 不干扰阅读,配置化之后改一行就行。
pdf: watermark: opacity: 0.3 font-size: 40 rotation: 45 sign: stamp-width: 120 stamp-height: 120 margin-right: 50 margin-bottom: 80 font: path: fonts/simsun.ttc index: 0最后说补日志。PDF 操作失败时,异常信息往往很模糊,比如“文档损坏”可能是模板问题,也可能是读取流被提前关闭。我习惯在关键步骤打日志:读取页数、签章坐标、水印页数、替换命中次数。这样出问题时能快速定位是哪个环节。
还有一个改造点是异步化。批量生成 PDF 是 CPU 密集型操作,放在 HTTP 请求线程里会拖慢响应。常见做法是把 PDF 生成任务丢到线程池,接口返回任务 ID,前端轮询进度。注意PdfReader和PdfWriter不是线程安全的,每个任务要独立创建实例,不要复用。
验证改造是否成功,我一般会写一个对比测试:用同一份模板,分别走改造前和改造后的代码,对比生成的 PDF 字节数、页数、文本提取结果。如果字节数差异超过 5%,说明内容有变化,需要排查。这个习惯是从一次血泪经验来的——当时改完签章逻辑,测试说“看起来没问题”,结果上线后发现公章盖在了第二页,因为模板第一页是封面没有正文。从那以后我每次改 PDF 相关代码,都强制走一遍“提取文本 + 对比页数 + 检查签章坐标”的验证流程。希望帮到你。
本文还有配套的精品资源,点击获取