Java用iText实现PDF电子签章、斜水印与文本替换实战

发布时间:2026/9/8 22:45:38
Java用iText实现PDF电子签章、斜水印与文本替换实战 简介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.xcom.lowagie.text包名大量中文教程都基于这个版本遇到问题更容易搜到答案。新项目且预算充足优先iText 7com.itextpdf.kernel包名架构更新签章和清理功能更完善。永远不要用4.2.x之前的版本那些版本存在已知安全漏洞尤其是处理不可信PDF时容易出问题。还有一个特别容易踩的坑就是BouncyCastle。iText的核心加密和签章功能依赖BouncyCastle提供密码学实现。执行签章时经常会报java.lang.NoClassDefFoundError: org/bouncycastle/jce/provider/BouncyCastleProvider原因就是项目中根本没有引入bcprov依赖或者引入了多个版本互斥。解决方式很直接dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15on/artifactId version1.68/version /dependency dependency groupIdorg.bouncycastle/groupId artifactIdbcpkix-jdk15on/artifactId version1.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());这行代码加上之后高版本JDK9以上可能会因为模块限制报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工具箱”后面接什么业务需求都只需要往里面加方法而不需要重写整个流程。本文还有配套的精品资源点击获取