简介:面向Java开发者的PDF处理源码包,围绕iText库演示创建文档、电子签章、斜字水印和文本替换等常见需求,适合具备初步Java基础、需要在项目中集成PDF功能的开发者,尤其适用于合同签章、文件批注及批量生成报表等场景。压缩包共三十二个文件,包含七个Java源码与九个已编译类文件,并附带五个核心依赖库(其中含亚洲字体支持与安全加密相关组件),同时提供两份签名测试证书、若干图片素材和Eclipse工程配置文件,整体大小为8.77MB,解压后可直接导入开发环境运行查看效果。目前已有1571人学习下载。通过阅读源码,可以掌握创建页面的基本流程、添加电子签名时的证书配置、设置水印透明度与旋转角度,以及替换文本内容的具体写法;还能理解各依赖包在工程中的实际用途,便于将示例迁移到自己的项目中,减少踩坑。
1. 用iText操作PDF文档值不值:先分清你要的是Document还是PdfReader
处理PDF这件事,大多数人以为找个桌面工具或者在线网页就能搞定,真到你手上是每周几十份合同要套模板、盖电子章、打水印、改编号时,就发现人工根本扛不住。用iText操作PDF文档(创建、签章、斜字水印、文本替换)是把这四件事收进Java代码的最稳路径:创建是生成模板,签章是合规闭环,水印和替换是批量生产。适合已经会写Java但没碰过PDF底层的从业者。下面所有代码以iText 5.5.13为基线,跑通后想换7也知道差异在哪。
2. PDF版本选型与环境:iText 7还是5,坐标和中文编码先定死
2.1 三个分支:iText 5、iText 7、OpenPDF到底该选谁
常见做法是直接搜“itext maven”然后复制坐标,结果代码里冒出一堆com.lowagie.text.Document、com.itextpdf.text.Document、com.itextpdf.kernel.pdf.PdfDocument,编译错误一个接一个。先分清分支再写代码。
iText 5 的坐标是com.itextpdf:itextpdf,包名是com.itextpdf.text和com.itextpdf.text.pdf,存量项目里用得最多,网上搜到的旧博客、老合同模板、内部工具几乎都建立在它上面。iText 7 拆成了kernel、io、layout、sign等多个模块,包名以com.itextpdf.kernel开头,新项目建议直接上7。OpenPDF是另一个分支,包名保留com.lowagie.text,基于iText 4时代代码演化,许可证友好,但功能更新慢,签章外的水印替换完全够用。
选型只有一个硬约束:整个项目只能有一条主线,不要5和7混着引。你引了itextpdf又引了itext7-core,类同名不同包,运行时全是NoClassDefFoundError。我一般建议,老项目修修补补就锁死5.5.x,新项目又不需要商业授权评估就上7,而需要规避AGPL风险且只用基础功能的团队直接看OpenPDF。
2.2 Maven依赖:itext-asian和BouncyCastle必须提前进pom
只有itextpdf一个依赖是不够的。中文字体定义在itext-asian里,数字签章要调BouncyCastle的加解密类,这两个缺了,前面三章代码跑都跑不起来。下面是一份能直接用的pom片段:
<properties> <itext.version>5.5.13.2</itext.version> <bc.version>1.78.1</bc.version> </properties> <dependencies> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>${itext.version}</version> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itext-asian</artifactId> <version>5.2.0</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>${bc.version}</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcpkix-jdk18on</artifactId> <version>${bc.version}</version> </dependency> </dependencies>itext-asian提供STSong-Light这类CJK字体定义,没有它,后面BaseFont.createFont("STSong-Light", ...)直接抛DocumentException。BouncyCastle的bcprov和bcpkix版本必须一致,签名生成PKCS#7数据时同时用到这两个包。注意JDK版本:JDK 8环境把jdk18on换成jdk15on,否则会报UnsupportedClassVersionError。这是最常见的第一坑,别到运行时才回头改pom。
2.3 中文编码:先声明STSong-Light,别把字体写成Helvetica
PDF里没有“系统字体”概念,Font对象必须绑定BaseFont。新手最容易写new Font(FontFamily.HELVETICA, 14, Font.NORMAL)然后往里塞中文,生成的PDF打开全是方块。正确姿势是先声明支持中文的BaseFont:
BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font font = new Font(bf, 14, Font.NORMAL, BaseColor.BLACK);第一个参数是字体名称,第二个是编码映射,UniGB-UCS2-H表示简体中文字符映射到CID字体。第三个参数用NOT_EMBEDDED时不把字体文件塞进PDF,文件小;如果目标机器或打印服务商没有Adobe中文字库,就要改成BaseFont.EMBEDDED,体积大但显示稳。iText 7里对应的写法是PdfFontFactory.createFont("STSong-Light", "UniGB-UCS2-H", PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDING),思路一样。
2.4 读文件还是读流:PdfReader的两种构造方式
new PdfReader("demo.pdf")和new PdfReader(new FileInputStream("demo.pdf"))看着一样,实际上差异不小。传文件路径时iText可以按需读取页对象,磁盘占用少;传InputStream时整个文件会被载入内存,大文件容易触发OutOfMemoryError。还有一点:同一个InputStream传给两个PdfReader,第二次读取会拿到空流,因为流被你new的第一个reader消费掉了。所以凡是写文件处理工具,第一选择永远传路径或字节数组,不要图方便传流。
3. 创建PDF与签章:模板、证书和两个必调参数
3.1 创建PDF的最小代码:Document配PdfWriter
创建PDF核心就三件事:建Document、挂PdfWriter、往里add元素。先跑通一个最小例子:
import com.itextpdf.text.*; import com.itextpdf.text.pdf.*; public class CreatePdf { public static void main(String[] args) throws Exception { Rectangle pageSize = new Rectangle(595, 842); Document document = new Document(pageSize, 72, 72, 72, 72); 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, 14, Font.NORMAL); document.add(new Paragraph("合同编号:{{CONTRACT_NO}}", font)); document.add(new Paragraph("本协议由甲、乙双方共同签署。", font)); document.close(); } }new Rectangle(595, 842)是A4竖版,单位是点,1点等于1/72英寸,换成A4横版就是(842, 595)。Document构造函数后面四个72是页边距,单位同样是点,约2.54厘米。document.close()会关闭Writer并写出文件,这一步不能省。注意占位符{{CONTRACT_NO}},后面文本替换章节会拿它当匹配对象。
生产环境里不会只塞两段话,你需要加PdfPTable做条款表格,用setHeaderRows让表头跨页重复,用document.newPage()控制强制分页。生成目录则用PdfAction配合本地目标,文本替换做不了目录页码的自动更新,所以动态页码一般在生成阶段就写死。
3.2 表格、页眉页脚与可变数据占位符
合同模板常见的布局是“标题 + 双方信息表 + 条款 + 页脚”。PdfPTable是iText 5里最稳定的表格类:
PdfPTable table = new PdfPTable(3); table.setWidthPercentage(100); table.setSpacingBefore(12); table.addCell(new PdfPCell(new Phrase("甲方", font))); table.addCell(new PdfPCell(new Phrase("乙方", font))); table.addCell(new PdfPCell(new Phrase("签订日期", font))); document.add(table);页眉页脚不要手动add到正文流里,用PdfPageEventHelper的onEndPage在每一页的PdfWriter.getOverContent(i)上画,不然表格跨页时页脚位置会乱。页脚画法和水印类似,是拿PdfContentByte直接往页面内容流里写字,后面水印章节的代码可以直接复用。
3.3 证书准备:用keytool生成PKCS12而不是JKS
电子签章要私钥和证书链。测试直接用keytool自签一个PKCS12文件,注意-storetype必须写PKCS12,不要用JDK默认的JKS,PDF标准的PKCS#7签名解析对PKCS12兼容性更好,Adobe Reader也更认。
keytool -genkeypair -alias contract -keyalg RSA -keysize 2048 \ -validity 3650 -storetype PKCS12 -keystore contract.p12 \ -storepass changeit -dname "CN=Demo Signer, OU=Dev, O=Demo, C=CN"生产环境找CA签发的数字证书,通常也是导成.p12发给开发。记住alias和storepass,后面代码要从KeyStore里按alias取PrivateKey和证书链。
3.4 可见签名域的签章代码:参数逐个说清楚
签章和普通写入最大的区别是,签章用的不是PdfWriter而是PdfStamper.createSignature。代码如下:
PdfReader reader = new PdfReader("demo.pdf"); FileOutputStream fos = new FileOutputStream("demo_signed.pdf"); PdfStamper stamper = PdfStamper.createSignature(reader, fos, '\0'); KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(new FileInputStream("contract.p12"), "changeit".toCharArray()); PrivateKey key = (PrivateKey) ks.getKey("contract", "changeit".toCharArray()); X509Certificate[] chain = (X509Certificate[]) ks.getCertificateChain("contract"); PdfSignatureAppearance sap = stamper.getSignatureAppearance(); sap.setCrypto(key, chain, null, PdfSignatureAppearance.SELF_SIGNED); sap.setReason("甲方签署"); sap.setLocation("上海"); Rectangle rect = new Rectangle(360, 700, 520, 790); sap.setVisibleSignature(rect, 1, "contract_sign"); stamper.close();这里逐个解释。createSignature第三个参数传'\0'表示临时文件前缀使用默认策略,数据会先落临时文件再增量写进demo_signed.pdf。setCrypto四个参数:私钥、证书链、null表示摘要算法走iText默认安全策略、SELF_SIGNED是外观类型。setVisibleSignature三个参数最关键:签名区矩形(左下角x、左下角y、右上角x、右上角y,单位点)、页码、签名域名字。域名字不能和模板里已有的AcroForm字段重复,否则签章会被覆盖。
注意顺序:所有内容操作必须在stamper.close()之前完成,close之后这个文件就是“已签名状态”,不能再拿PdfStamper改,否则签名哈希失效。后面所有水印和文本替换,都要排在签章之前做。
3.5 签名外观里中文变方块:setLayer2Font要单独声明
setReason("甲方签署")如果写成中文,签名面板显示时常会乱码,是因为签章外观默认字体不支持中文。在setCrypto前后补一行:
BaseFont signFont = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); sap.setLayer2Font(signFont);setLayer2Font控制签章外观第二层文本渲染,这层承载reason、location、signer名字。不做这一步,Acrobat里看签名属性是问号,业内叫“签名面板中文乱码”,也是最多人忽略的签章坑。
4. 斜字水印与文本替换:定位、遮罩和重写三步走
4.1 斜水印用什么画:PdfContentByte加旋转和透明度
常见做法是每个页面循环取getUnderContent(i),在内容流里画一段旋转过的文字。放UnderContent是让水印垫在正文底下,不遮挡阅读;如果希望水印压住内容防截图,就用getOverContent(i)。核心代码:
PdfReader reader = new PdfReader("demo.pdf"); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream("demo_wm.pdf")); BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); for (int i = 1; i <= reader.getNumberOfPages(); i++) { PdfContentByte cb = stamper.getUnderContent(i); PdfGState gs = new PdfGState(); gs.setFillOpacity(0.3f); cb.saveState(); cb.setGState(gs); cb.beginText(); cb.setFontAndSize(bf, 42); cb.setColorFill(new GrayColor(0.7f)); cb.showTextAligned(Element.ALIGN_CENTER, "内部资料", 297.5f, 421f, (float) Math.toRadians(45)); cb.endText(); cb.restoreState(); } stamper.close();showTextAligned最后一个参数是弧度,Math.toRadians(45)产生从左上到右下的45度斜角。水印位置按页面中心写死,A4页面就是x=297.5点、y=421点。PdfGState的setFillOpacity(0.3f)控制透明度,不设它水印就是实心黑字,既难看又盖正文。beginText和endText必须成对,否则内容流状态错乱,之后再做文本替换会定位不准。这里有个玄学经验:透明度值在0.25到0.4之间观感最好,在0.2以下打印出来几乎看不见。
4.2 文本替换没有现成API:RenderListener先把坐标捞出来
PDF内部是内容流,不是Word那种可编辑段落,iText没有“查找替换文本”这个接口。常见做法分三步:先定位关键字所在矩形,再用底色遮罩盖掉旧字,最后在同样位置写入新文本。
定位要自己实现RenderListener,iText在扫描每个文本块时会回调renderText,把关键字匹配到的坐标存下来:
static class KeywordFinder implements RenderListener { private String keyword; public List<Rectangle> hits = new ArrayList<Rectangle>(); KeywordFinder(String keyword) { this.keyword = keyword; } public void beginTextBlock() {} public void renderText(TextRenderInfo renderInfo) { String chunk = renderInfo.getText(); if (chunk.contains(keyword)) { Vector start = renderInfo.getBaseline().getStartPoint(); Vector end = renderInfo.getBaseline().getEndPoint(); float width = end.get(Vector.I1) - start.get(Vector.I1); float lineHeight = renderInfo.getAscentLine().getStartPoint().get(Vector.I2) - renderInfo.getDescentLine().getStartPoint().get(Vector.I2); float bottom = renderInfo.getDescentLine().getStartPoint().get(Vector.I2); hits.add(new Rectangle(start.get(Vector.I1), bottom, width, lineHeight)); } } public void renderImage(ImageRenderInfo renderInfo) {} public void endTextBlock() {} }拿到坐标后逐页执行遮罩和重写:
PdfReader reader = new PdfReader("demo_wm.pdf"); PdfStamper stamper = new PdfStamper(reader, new FileOutputStream("demo_out.pdf")); PdfReaderContentParser parser = new PdfReaderContentParser(reader); Font font = new Font(bf, 12, Font.NORMAL); for (int i = 1; i <= reader.getNumberOfPages(); i++) { KeywordFinder finder = new KeywordFinder("{{CONTRACT_NO}}"); parser.processContent(i, finder); for (Rectangle rect : finder.hits) { PdfContentByte canvas = stamper.getOverContent(i); canvas.saveState(); canvas.setColorFill(BaseColor.WHITE); canvas.rectangle(rect.getLeft(), rect.getBottom(), rect.getWidth(), rect.getHeight()); canvas.fill(); canvas.restoreState(); canvas.saveState(); canvas.beginText(); canvas.setFontAndSize(bf, 12); canvas.setTextMatrix(rect.getLeft(), rect.getBottom()); canvas.showText("NO.2025-001"); canvas.endText(); canvas.restoreState(); } } stamper.close();一个坑:PDF生成时同一个可见文本可能被拆成多个chunk,比如{{CONTRACT_NO}}被拆成{{CONTRACT和NO}}两段,contains匹配不到。遇到这种情况要在renderText里做“跨块拼接”,维护一个字符串缓冲区,超过chunk长度再判断。生产模板里占位符只要不是被字符级拆开,一般都能直接命中。命中了多个矩形说明关键字出现多次,循环重写即可。
4.3 替换后的字号要按宽度自适应
新文本和旧文本宽度不一致,还按12号字硬写,会左右溢出,盖住表格边框。计算很直接:
float oldWidth = bf.getWidthPoint("{{CONTRACT_NO}}", 12); float newWidth = bf.getWidthPoint("NO.2025-001", 12); float realSize = 12 * oldWidth / newWidth; if (realSize > 12) { realSize = 12; }getWidthPoint按当前字号返回字符串宽度。newText比oldText长,ratio小于1,字号自动缩小;newText短很多,ratio大于1,这时按1处理,否则字会超出原区域向上膨胀。如果替换文本特别长,缩小到8号字都放不下,必须手动换行或用缩写,而不是硬塞。
4.4 严格脱敏别用白块遮罩,用Redaction
白块遮罩对屏幕展示有效,但旧文本还留在PDF内容流里,用Adobe Reader框选复制仍然能把旧字选出来,只是看不见。如果需求是“把合同中的身份证号彻底抹掉”,这叫redaction,iText 5里有com.itextpdf.text.pdf.pdfcleanup.PdfCleanUpProcessor可以做内容级清理,它会把匹配区域的内容真正从内容流里删除,而不是盖白块。开源自测足够,严格合规场景要找专门的redaction库或商业版,不要拿白块骗审计。
5. PDF实操避坑:汉字乱码、签名失效与替换越界
5.1 中文变成方框,八成是itext-asian没进classpath
现象:创建的PDF里中文全部显示为□□□,英文和数字正常。 原因:字体定义缺失,页面里没有CMap映射,PDF阅读器找不到STSong-Light对应的字形。 解决:检查pom是否引入com.itextpdf:itext-asian,mvn dependency:tree看一下依赖有没有被打进fat jar。先加依赖再重跑,不要先怀疑代码。另外确定用的是STSong-Light加UniGB-UCS2-H,而不是new Font(FontFamily.HELVETICA)。
5.2 签章后Adobe Reader提示“文档已被修改”
现象:签章流程跑完,Acrobat打开签名面板显示“文档已被修改或损坏”,签名验签失败。 原因:签章之后又有一个PdfStamper打开同一个文件做了写入,增量更新把签名哈希覆盖了。也可能是签章close之后又向输出流里写了一次内容。 解决:记住一条铁律——签名永远是整条流水线的最后一步。先创建、加水印、做文本替换,得到一个新文件,最后对这个新文件签章,签完的文件放进单独目录,不再做任何写入。如果不小心对已签文件做了第二次stamper操作,只保留签名前的版本重新走流程,没有后悔药。
5.3 替换标题后文字盖住表格线
现象:用{{NO}}替换成超长编号,结果新文字超出旧位置,压到右边单元格边框上,页面布局崩了。 原因:宽度没有计算,替换文本比原文本宽,字号还是旧字号。 解决:按4.3节的比例公式缩字号。如果原位置特别窄,另一种方案是缩小字号的同时,用setTextMatrix把新文本右对齐到旧区域右边缘,避免左侧出现大块空白。
5.4 水印位置在代码里明明居中,打印出来却跑到页面外
现象:水印参数按脑海中“屏幕坐标”填的,结果文字跑到页面边缘或被裁掉。 原因:PDF坐标系原点在左下角,y轴向上,不是UI界面的左上角原点。把showTextAligned的y写大了,文字就冲到页面外。 解决:A4页面高842点,y最大值不能超过842减页边距。要放“页面垂直居中”,y取421附近;要放“底部”,y取50到72之间。调试时先用PdfReader.getPageSize(i)打印出页面宽高,再算位置。
5.5 签名面板里中文全是问号
现象:签章后证书信息、reason、location在Adobe Reader里中文显示成??,但签章有效。 原因:签章外观的layer2没有指定中文字体,默认的Helvetica不包含中文字形。 解决:签章前调用sap.setLayer2Font(signFont),signFont用STSong-Light生成。这个坑和创建PDF乱码的根源一样,但错误发生的位置在签名外观层,排查时不要只查正文创建代码。
5.6 排查顺序:从依赖到文件状态按序过一遍
遇到问题别东改一行西改一行。我建议按这个顺序查:第一看依赖,itext-asian和BouncyCastle是否都在;第二看字体,所有中文渲染必须经过BaseFont;第三看文件状态,确认当前文件是不是已经被签名过,被签过就不能再改;第四看坐标,把目标页宽高打出来核一遍;最后看内容流,替换文本后重新生成PDF文本看看有没有冗余旧字。按这个顺序能覆盖90%的翻车现场。
6. 一个门面工具:把四件事串成一条流水线并自查
6.1 PdfOps串联:水印替换在前,签章殿后
写个门面类把四个操作串起来,顺序不要记反:
public class PdfOps { public static void main(String[] args) throws Exception { File workDir = new File("work"); workDir.mkdirs(); createContract(new File(workDir, "step1_plain.pdf")); addWatermark(new File(workDir, "step1_plain.pdf"), new File(workDir, "step2_wm.pdf"), "内部资料"); replaceKeyword(new File(workDir, "step2_wm.pdf"), new File(workDir, "step3_replaced.pdf"), "{{CONTRACT_NO}}", "NO.2025-001"); sign(new File(workDir, "step3_replaced.pdf"), new File(workDir, "final_signed.pdf"), "contract.p12", "changeit"); } }每步落一个新文件,stage后面带版本号。这样任何一步失败,都能从上一个文件重跑。这些中间文件就是生产的后悔药。所有PDF操作类都复用同一个BaseFont实例放到类静态变量里,而不是每页创建,能显著降低大文件的内存压力。我自己写PDF工具时,永远把“签名最后做”五个字写在类注释里,签完名的文件一律放到单独目录,不再碰它。
6.2 验证自查:从三个维度确认产出可用
签章文件生成后,不要只盯着浏览器预览看。第一,用Adobe Reader打开,进入签名面板确认签名状态是“有效”,浏览器内置PDF预览器不校验签章,参考价值有限。第二,用“选择文本”工具框选替换过的区域,确认能选中新文本,而旧占位符不能被选中,这能验证遮罩是否掩盖住了内容流。第三,把PDF缩放200%检查水印透明度是否影响正文阅读,透明度在0.25到0.4之间通常没问题。如果文件需要给客户或审计,建议额外跑一次qpdf --check验证PDF结构完整性,再做批量交付。
这套流程在几百份文件的批量场景下也扛得住。要注意的是每份文件都要走完整的“创建到签章”链路,不要尝试一份模板签多个不同编号,签章会关联文件内容,复制签名是无效的。希望帮到你。
本文还有配套的精品资源,点击获取