简介:iText是Java生态中处理PDF的成熟方案,这份面向开发者的源码包演示如何基于开源iText库创建PDF、添加电子签章、生成斜体水印并替换文本,覆盖文档处理中的高频场景。压缩包共32个文件,包含Java源文件与编译后的class、打包好的依赖jar包、示例证书与图片素材,整体8.77MB。目前已有1570人学习下载,包内是一个可直接导入Eclipse的工程,src目录为源码、bin目录为编译输出,并附有工程配置文件、证书文件和readme说明,方便快速定位关键代码。通过阅读源码,可掌握PdfWriter创建页面、PdfStamper配合数字证书完成签名、PdfFormXObject绘制带透明度的斜字水印,以及AcroFields实现文本替换等关键技术。对于希望把PDF生成、签署、水印保护等功能集成到业务系统中的Java团队而言,这套代码提供了清晰的实现思路和可复用的工具基础。 去年有个项目,要求在一个老旧的审批系统里加“PDF在线预览、电子签章、防篡改水印”一套东西。我第一反应是直接用浏览器打印,结果被业务部门一句“要能盖红章、要能替换合同条款里的错误文本”给怼了回来。研究了一圈,最后选定iText作为核心操作库,把创建、签章、斜字水印、文本替换四件事全做完了。这篇就来拆解一下整个落地过程,包括最容易被忽略的版本、许可证、底层绘制原理,以及我实际踩过的坑。
如果你是刚接触PDF处理的Java开发者,或者是维护老系统时需要临时给PDF加功能,这篇文章可以直接照着做。我以iText 5系列为主,因为它资料多、API稳定、很多老项目还在用,iText 7的差异我会在关键处单独说明。
1. 为什么必须读一遍iText的许可证和版本再动手
先说结论:iText不是一个“拿来就随便用”的库,选错版本或者忽视许可证,后面可能吃法律风险。iText核心类库采用的是AGPL协议,这意味着如果你把它集成进了商业软件,并且没有向用户开源自己的代码,那你需要购买商业授权。很多公司踩过这个坑:开发阶段没事,一到上线前法务审计就被卡住。
我的建议是,先确认项目是内部工具还是对外商业产品。内部工具(比如内部审批系统)一般风险较小,但如果涉及对外SaaS服务,最好走一遍公司的开源合规流程。iText 5和iText 7在许可上差异不大,但API差别很大,升级成本不低,选定了就不要轻易换。
版本选择这块,我个人的倾向是:
- 老项目、资料多的场景用iText 5.x,
com.lowagie.text包名,大量中文教程都基于这个版本,遇到问题更容易搜到答案。 - 新项目且预算充足,优先iText 7,
com.itextpdf.kernel包名,架构更新,签章和清理功能更完善。 - 永远不要用4.2.x之前的版本,那些版本存在已知安全漏洞,尤其是处理不可信PDF时容易出问题。
还有一个特别容易踩的坑就是BouncyCastle。iText的核心加密和签章功能依赖BouncyCastle提供密码学实现。执行签章时经常会报java.lang.NoClassDefFoundError: org/bouncycastle/jce/provider/BouncyCastleProvider,原因就是项目中根本没有引入bcprov依赖,或者引入了多个版本互斥。解决方式很直接:
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.68</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcpkix-jdk15on</artifactId> <version>1.68</version> </dependency>注意版本号要和iText版本匹配。iText 5.5.x系列推荐用1.68左右,iText 7.1.x则推荐1.70以上。版本不匹配时,即使不报编译错误,运行阶段也会冒出各种奇怪的NoSuchMethodError,特别是签章时,非常折磨人。
处理PDF文件还有个很重要的习惯:始终在内存或临时目录中操作,不要直接在原文件上覆盖写。iText读取PDF时会对文件加锁,原文件覆盖会导致文件损坏,这个在后面章节会具体提。
2. 创建PDF:先把中文字体和Document基础件焊牢
最初级的需求就是“创建PDF”。iText 5里最典型的流程就是用Document加PdfWriter,但如果你按网上老教程直接写英文,一点问题没有;一旦加中文,输出的PDF里很可能就是一个“空块”或者一堆乱码。原因在于PDF本身是一个“无字体不显示”的格式,文件里没有对应字体信息时,阅读器就用默认字体替代,而默认字体往往不含中文字形。
我的做法是先创建一个中文字体对象,再把它传给所有需要文字的组件:
Document document = new Document(PageSize.A4, 50, 50, 50, 50); PdfWriter.getInstance(document, new FileOutputStream("demo.pdf")); document.open(); BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font font = new Font(bf, 12, Font.NORMAL); document.add(new Paragraph("这是一份用iText创建的PDF文档。", font)); document.close();这里BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED)的意思是使用系统自带的STSong-Light字体,编码方式是UniGB-UCS2-H,不嵌入字体文件。NOT_EMBEDDED适合普通文本,因为文件体积小;但如果你的PDF要拿去打印店或给别人跨平台打开,最好用BaseFont.EMBEDDED并指定一个系统字体文件路径,比如Windows下的C:/Windows/Fonts/simhei.ttf。
BaseFont bf = BaseFont.createFont("C:/Windows/Fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED); Font font = new Font(bf, 12, Font.NORMAL);为什么推荐IDENTITY_H?因为很多字体不支持UniGB-UCS2-H这种别名,而IDENTITY_H是“使用字体内部的字形ID”的横向编码,配合嵌入字体基本通吃中文、日文和特殊符号。代价是文件会变大,但换来的是稳定。
创建PDF这块还有个容易忽略的点:document.add()之前一定要先open(),所有内容写完一定要close()。如果你在close()之后还想写内容,iText会直接抛DocumentException,别问我怎么知道的。
如果你需要复杂排版,比如表格、图片、多栏,iText 5的PdfPTable和PdfPCell也能应付。我的建议是:能用PdfPTable实现的就别用凑空格的方式排版,因为空格在不同字体下宽度完全不一致,稍微换台机器就全乱了。表格方案兼容性要稳得多,这也是我做合同类文档时的一个经验。
3. 电子签章:证书链、BouncyCastle和签章外观的串联
签章是这四个功能里原理最复杂的一个,也是网上求助最多的一个。它不是一个“盖个图片上去”的事,而是通过数字证书对PDF内容做签名,任何一点改动都会导致验签失败。所以签章的完整链路是:加载PKCS12证书文件(.p12/.pfx) -> 取出私钥和证书链 -> 创建签章外观 -> 写入指定页面和区域 -> 计算签名并加密写入。
先说最容易出错的环境步骤。签章需要BouncyCastle在JVM里注册Provider,因为Java标准库对PKCS12和CMS/PKCS#7签名的支持并不完整。如果你用的是JDK 8,代码里要显式加一行:
Security.addProvider(new BouncyCastleProvider());这行代码加上之后,高版本JDK(9以上)可能会因为模块限制报AccessControlException,此时可以换一种注册方式,在$JAVA_HOME/lib/security/java.security里追加security.provider条目,或者在启动参数里加上-Djava.security.properties。这个坑在对接第三方电子签章平台时非常常见,很多报错都是Provider没有正确加载导致的。
接下来是核心代码:
KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(new FileInputStream("keystore.p12"), "password".toCharArray()); PrivateKey pk = (PrivateKey) ks.getKey("alias", "password".toCharArray()); Certificate[] chain = ks.getCertificateChain("alias"); PdfReader reader = new PdfReader("input.pdf"); FileOutputStream fos = new FileOutputStream("signed.pdf"); PdfStamper stp = PdfStamper.createSignature(reader, fos, '\0'); PdfSignatureAppearance sap = stp.getSignatureAppearance(); sap.setCrypto(pk, chain, null, PdfSignatureAppearance.WINCER_SIGNED); sap.setVisibleSignature(new Rectangle(120, 100, 320, 200), 1, "sig"); sap.setSignDate(new GregorianCalendar()); stp.close();看似简单,实际有四个细节决定成败:
第一,setVisibleSignature(Rectangle, pageNum, fieldName)的坐标原点在页面左下角,而不是很多UI工具里的左上角。如果你照着PDF预览右上角的视觉位置写坐标,签章会跑到页面外面去。需要换算:y = pageHeight - topY - height。
第二,fieldName必须唯一。如果同一个字段名用了两次,Adobe Reader会报“签名域冲突”。我习惯用UUID加业务标识拼字段名,避免重复。
第三,签章区域不要设置得太小。区域太小,文字会自动缩小,红章中间的字体挤成一团,验章时视觉效果很差。我的经验是最小高度不要少于60pt,宽度不要少于120pt。
第四,setCrypto的签名机制一般用WINCER_SIGNED,它兼容性最好,Adobe Reader、国产PDF阅读器都能识别。其他参数如CERTIFIED_NO_CHANGES_ALLOWED是审计级签名,一旦加上,后续任何改动都会使签名失效,业务上要慎重。
如果你需要自定义章的外观——比如圆形红章、带五角星、带单位名称——不能用普通的图片直接贴,而要用PdfTemplate先绘制一个矢量层:
PdfTemplate tpl = PdfTemplate.createTemplate(stp.getWriter(), 200, 100); tpl.setColorFill(BaseColor.RED); tpl.circle(100, 50, 40); tpl.fill(); tpl.beginText(); tpl.setFontAndSize(bf, 12); tpl.setTextMatrix(50, 50); tpl.showText("测试章"); tpl.endText(); sap.setSignatureGraphic(tpl); sap.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC);这里用矢量绘制的好处是,PDF阅读器按矢量渲染,放大不模糊,打印出来也清晰。用位图做章的话,分辨率不够就会出现毛边,非常影响观感。
4. 斜字水印:变换矩阵与PdfContentByte的绘制逻辑
水印看起来简单,但“斜字水印”涉及PDF底层的内容流绘制,如果你不理解变换矩阵,很容易写出“躺平”的或错位的水印。
先讲原理。PDF页面内容流可以看作一系列绘图指令,iText的PdfContentByte就是用来执行这些指令的Java接口。你要在已有PDF上加内容,有两种方式:getOverContent(pageNum)写在原内容之上,getUnderContent(pageNum)写在下层。水印我建议放上层,这样即使用PDF编辑器尝试覆盖,水印依然可见。
斜字水印的核心是文本矩阵变换。平常用setTextMatrix(x, y)只是设置文字位置,文字方向是水平向右的。要让文字旋转一个角度,需要设置完整的六参数矩阵setTextMatrix(a, b, c, d, e, f),这个矩阵的含义是:
x' = a*x + c*y + e y' = b*x + d*y + f如果旋转角度为θ,则:
a = cos(θ)b = sin(θ)c = -sin(θ)d = cos(θ)e和f是平移坐标
所以,一个从页面左下角开始、倾斜45度的水印可以这样写:
PdfReader reader = new PdfReader("input.pdf"); PdfStamper stp = new PdfStamper(reader, new FileOutputStream("output.pdf")); BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); PdfGState gs = new PdfGState(); gs.setFillOpacity(0.3f); for (int i = 1; i <= reader.getNumberOfPages(); i++) { PdfContentByte cb = stp.getOverContent(i); cb.saveState(); cb.setGState(gs); cb.beginText(); cb.setFontAndSize(bf, 36); cb.setColorFill(BaseColor.GRAY); double angle = Math.toRadians(45); float cos = (float) Math.cos(angle); float sin = (float) Math.sin(angle); cb.setTextMatrix(cos, sin, -sin, cos, 180, 300); cb.showText("内部资料 禁止外传"); cb.endText(); cb.restoreState(); } stp.close();这里几个关键点:
saveState()和restoreState()是成对出现的。它们保护了当前图形状态(颜色、透明度、字体、线宽),避免这一页的水印设置影响到下一页。setFillOpacity(0.3f)是透明度设置。如果忘了设透明度,水印会像大黑块一样压住正文,很难看。透明度建议0.15~0.35之间,既要可识别,又不影响阅读。- 坐标
180, 300是旋转后文字的起点。如果你想把水印放在页面正中心,需要先算页面尺寸。假设页面是A4,宽595、高842,那么起点可以设为(width/2 - textWidth/2, height/2),但旋转后的位置会偏移。最简单粗暴的办法是直接枚举几个固定点,多铺几行水印,效果反而更均匀。
如果你需要“多行多列”的平铺水印,外层套两个循环就行:
for (int row = -2; row <= 2; row++) { for (int col = -1; col <= 3; col++) { cb.setTextMatrix(cos, sin, -sin, cos, 100 + col * 180, 200 + row * 120); cb.showText("内部资料"); } }用这种方法铺出来的水印是整页斜条纹的,既能防盗,也不像单条水印那样容易被人用局部截图裁掉。
还有个容易踩的坑:如果目标PDF是扫描件(图片型PDF),普通文字水印没问题;如果目标PDF是文字型,要确认水印是否遮住了关键内容。这时候建议水印位置稍微偏一点,或者透明度再调低。
5. 文本替换:定位、遮罩、重绘三步走
文本替换是这四个功能里最“坑”的一个。PDF不像Word那样有“段落”和“文本流”,它只有一堆绘制指令,文字是“画”上去的。所以“文本替换”的本质是:找到旧文字的位置 -> 用白色或其他背景色遮盖 -> 在新位置重新绘制新文字。
iText原生没有提供replaceText这种傻瓜方法。网上有一种方案是先解压PDF内容流,把PDL文本直接字符串替换掉,但那是极其脆弱的做法:中文内容在PDF内通常以十六进制编码存储,直接被压缩了,字符串替换会直接破坏内容流,导致文档打不开。我不推荐在生产环境用这种方式。
我常用的稳定方案是:先用iText解析PDF文本位置,再做遮盖和重绘。
第一步,解析文本位置。iText 5中可以用PdfReaderContentParser配合RenderListener实现:
PdfReader reader = new PdfReader("input.pdf"); PdfReaderContentParser parser = new PdfReaderContentParser(reader); for (int pageNum = 1; pageNum <= reader.getNumberOfPages(); pageNum++) { parser.processContent(pageNum, new RenderListener() { public void beginTextBlock() {} public void endTextBlock() {} public void renderText(TextRenderInfo renderInfo) { String text = renderInfo.getText(); if (text.contains("旧公司名称")) { // 拿到文字左下角坐标和宽高 Vector start = renderInfo.getDescentLine().getStartPoint(); Vector end = renderInfo.getAscentLine().getEndPoint(); float x = start.get(Vector.I1); float y = start.get(Vector.I2); float width = end.get(Vector.I1) - start.get(Vector.I1); float height = end.get(Vector.I2) - start.get(Vector.I2); // 记录到一个集合里 } } public void renderImage(ImageRenderInfo renderInfo) {} }); }这里用getDescentLine().getStartPoint()和getAscentLine().getEndPoint()拿到的坐标是文本左下角和右上角的基线坐标,比getBaseline()更保险,因为它考虑了字体的行高。
第二步是遮罩。拿到了坐标之后,用PdfStamper.getOverContent(pageNum)在这个位置画一个白色矩形:
PdfContentByte cb = stp.getOverContent(pageNum); cb.saveState(); cb.setColorFill(BaseColor.WHITE); cb.rectangle(x - 2, y - 2, width + 4, height + 4); cb.fill(); cb.restoreState();这里往外扩容2像素是为了遮住旧字体可能带有的细微抗锯齿边缘。如果不扩容,替换后旧文字边缘会有残留,看起来就像没擦干净的铅笔字。
第三步是重绘。在同样的位置绘上新的文字:
cb.beginText(); cb.setFontAndSize(font, fontSize); cb.setColorFill(BaseColor.BLACK); cb.setTextMatrix(x, y + 2); cb.showText("新公司名称"); cb.endText();这一步有一个关键点:你必须知道原文字的字体、字号和颜色。如果新文字和旧文字字体不一致,替换后排版会明显突兀。我的做法是,在第一步renderText时就把renderInfo.getFont()和renderInfo.getFontSize()保存下来,重绘时直接复用。如果字体不能复用(比如原font是嵌入字体但是子集),就退而求其次用系统黑体替代,同时微调字号到视觉接近。
文本替换还有一个经典问题:替换后的文字长度和原来不一致。如果新文字比旧文字长,超出部分会溢出到旁边的内容上;如果短,则会在原区域留下一段空白。建议做“居中对齐”,即让新文字的中心点对齐旧文字的中心点:
float newWidth = bf.getWidthPoint(newText, fontSize); float startX = x + (width - newWidth) / 2f; cb.setTextMatrix(startX, y + 2);如果你的替换文本量大、位置多,建议把所有“旧文本位置”先收集完,再一次性地执行遮罩和重绘。否则位置会随着前面的修改而漂移,容易误伤相邻文本。
另外提醒一句,文本替换只适合“内容和格式不变、只是字变了”的简单场景。如果替换导致换行、段落重排,那基本上是重新生成PDF更靠谱,不要硬刚。
6. 实战中的报错排查:五条高频错误链
最后把我实际排查过程中印象最深的五条错误链列出来,每一条都对应一个具体根因。
NoClassDefFoundError: org/bouncycastle/jce/provider/BouncyCastleProvider
这是BouncyCastle依赖缺失或版本冲突。常见于Maven传递依赖中项目自己引用了低版本bcprov,而iText需要高版本。排查方法:执行mvn dependency:tree查看bcprov版本,确认只有一个版本,且高于iText要求的最低版本。我用这个命令解决过两次类似问题。
DocumentException: PdfReader not opened with owner password
这个报错出现的原因是PDF文件本身有加密权限,PdfReader以只读方式打开时没有权限进行PdfStamper操作。解决方案是先尝试用空密码或用户密码读取,如果还不行,得向业务方要文档的编辑密码。注意,破解加密权限是不合法的,遇到他方加密的文档只能走正规授权渠道。
替换文本后PDF打开报错“已损坏”
原因几乎都是直接对内容流字符串做了替换,破坏了内容流的语法结构。如果你看到哪个博客让你先解压再替换,劝你直接放弃,用上一章的“定位+遮罩+重绘”方案,稳得多。
中文水印显示为方块
水印文字显示为方块,通常是BaseFont没设置成功。要么是字体路径不对,要么是编码方式选错了。排查时优先确认字体文件是否存在。应用服务器上,字体文件未必和本地开发环境一致,常见的坑是Windows上有simhei.ttf,但Linux服务器没有。解决方案是把字体文件放到项目resources目录,然后通过绝对路径或类路径加载:
BaseFont bf = BaseFont.createFont("/fonts/simhei.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);签章后Adobe Reader提示“签名后文档已被修改”
这说明你在stp.close()之后又对PDF文件做了任何写操作,哪怕只是再塞一条元数据,签名也会失效。我见过有人签完章后又调了一遍“加图片水印”,结果签名状态直接红了。正确顺序是:先做所有内容修改(文本替换、水印),最后一步再签章。
这五类错误占了我排查工作的八成,如果你也遇到类似的报错,先从这几个方向下手,效率会高很多。
最后一个建议:iText操作PDF时,始终用“输入文件 -> 输出新文件”的工作方式,不要覆盖原文件。这既是为了防止中途异常导致源文件损坏,也是为了方便调试时对比前后差异。把上面的代码拆成几个独立工具类,造一个“PDF工具箱”,后面接什么业务需求都只需要往里面加方法,而不需要重写整个流程。
本文还有配套的精品资源,点击获取