1. 为什么PDF内容编辑总让人头疼
做过文档系统的人多半都有类似的经历:业务方丢过来一份几十页的PDF合同模板,说要把里面的甲方名称、金额、日期全部替换成动态数据,还要在指定位置盖上电子章或者插入签名图片。第一反应可能是找个PDF编辑器手动改,但一旦数量上去、字段变多,手动操作根本扛不住。这时候就得靠代码来解决,而Java生态里做PDF处理,iText几乎是绕不开的一个库。
iText能做什么?简单说,它把PDF的底层结构抽象成了对象模型,你可以像操作DOM一样去读取、修改、生成PDF。修改文本内容、在指定坐标插入图片、合并拆分文档、填充表单,这些都能干。它适合谁?后端开发、报表系统开发者、需要做合同自动化或者票据生成的工程师。哪怕你之前没接触过PDF底层格式,只要理解坐标系和内容流的概念,上手并不难。
但这里有个坑要先说清楚:iText修改已有PDF的文本,并不像改Word那样直接找到文字替换就行。PDF本质上是一堆绘制指令的集合,文字在里面的存储方式取决于生成工具。有些PDF的文字是可提取的,有些是曲线化的,还有些字体子集化之后连字符映射都对不上。所以“修改文本内容”这件事,得先看你的PDF是哪种类型,再决定用什么策略。这篇文章就把我踩过的坑和验证过的方案完整梳理一遍,从环境搭建到文本替换再到图片插入,每一步都给出可复现的操作。
2. 环境准备与版本选择的那些事
2.1 iText 5 还是 iText 7
这是第一个要做的决策。iText 5和iText 7的API差异非常大,几乎是两个不同的库。iText 5的包名是com.itextpdf.text,iText 7改成了com.itextpdf.kernel和com.itextpdf.layout。网上大量教程还是iText 5的,但新项目我建议直接上iText 7,原因有几个:模块化更清晰,对PDF 2.0支持更好,字体处理更完善,而且社区维护重心已经转移。
不过要注意License问题。iText 7是AGPL双许可,商业闭源项目需要购买商业授权。如果项目不能接受AGPL,可以考虑Apache 2.0协议的PDFBox作为替代,但PDFBox在布局和高级操作上没iText顺手。这个取舍得根据你的项目性质来定。
Maven依赖这样引入:
<dependency> <groupId>com.itextpdf</groupId> <artifactId>kernel</artifactId> <version>7.2.5</version> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>layout</artifactId> <version>7.2.5</version> </dependency> <dependency> <groupId>com.itextpdf</groupId> <artifactId>io</artifactId> <version>7.2.5</version> </dependency>kernel是核心,layout提供高层布局API,io负责读写。如果你要处理表单,还需要forms模块。
2.2 字体准备:中文处理的命门
iText默认只带了一套基础字体,处理中文必须自己引入字体文件。我一般用STSong-Light配合UniGB-UCS2-H编码,或者直接加载系统的TTF文件。用系统字体更稳妥,因为不同环境的字体可用性不一样。
PdfFont font = PdfFontFactory.createFont("fonts/simsun.ttf", PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED);这里IDENTITY_H是关键,它支持Unicode,能正确显示中文。PREFER_EMBEDDED表示优先嵌入字体子集,这样生成的PDF在任何设备上都能正常显示,不会因为对方没装字体而乱码。代价是文件会变大一些,但为了兼容性这个代价值得。
注意:字体文件路径不要用中文,某些操作系统下iText读取中文路径会出问题。把字体放在项目resources目录下,用类加载器读取最稳。
3. 修改PDF文本内容的核心思路
3.1 先搞清楚你的PDF是哪一类
动手写代码之前,先做一件事:用iText把PDF的文字内容提取出来看看。如果提取出来是乱码或者空,说明这个PDF的文字不可直接操作,得换思路。
PdfDocument pdfDoc = new PdfDocument(new PdfReader("input.pdf")); for (int i = 1; i <= pdfDoc.getNumberOfPages(); i++) { String text = PdfTextExtractor.getTextFromPage(pdfDoc.getPage(i)); System.out.println("第" + i + "页: " + text); } pdfDoc.close();提取正常,说明文字是以文本对象存储的,可以用替换策略。提取为空,可能是扫描件或者曲线化文字,那就只能走OCR或者重新生成的路子。
3.2 文本替换的两种策略
策略一:基于内容流的精确替换。这种方式适合文字结构规整、字体单一的PDF。原理是遍历页面内容流中的文本绘制指令,找到目标字符串,替换成新字符串。iText 7没有直接提供“替换文本”的API,需要自己解析内容流。
策略二:覆盖式替换。在目标文字上方画一个白色矩形盖住原文,再在相同位置写入新文字。这种方式简单粗暴,但要求背景是纯色,且新文字宽度和原文字接近,否则会露出破绽。适合字段位置固定、背景简单的场景,比如票据、标签。
我实际项目中用得最多的是策略二,因为大部分业务PDF的字段位置是固定的,背景也是白色,覆盖式替换稳定可靠,不容易因为字体子集化问题翻车。
3.3 定位目标文字的坐标
不管用哪种策略,都得先知道目标文字在页面上的位置。iText提供了LocationTextExtractionStrategy,可以拿到每个字符的坐标。
public class PositionStrategy extends LocationTextExtractionStrategy { private String searchText; private List<Rectangle> matches = new ArrayList<>(); public PositionStrategy(String searchText) { this.searchText = searchText; } @Override public void eventOccurred(IEventData data, EventType type) { super.eventOccurred(data, type); } @Override public String getResultantText() { return super.getResultantText(); } }更实用的做法是继承SimpleTextExtractionStrategy,重写renderText方法,在渲染每个文本块时判断是否包含目标文字,如果包含就记录下当前的变换矩阵和坐标。
public class TextPositionListener implements IEventListener { private String target; private List<float[]> positions = new ArrayList<>(); public TextPositionListener(String target) { this.target = target; } @Override public void eventOccurred(IEventData data, EventType type) { if (type == EventType.RENDER_TEXT) { TextRenderInfo info = (TextRenderInfo) data; String text = info.getText(); if (text != null && text.contains(target)) { Vector start = info.getBaseline().getStartPoint(); positions.add(new float[]{start.get(0), start.get(1)}); } } } public List<float[]> getPositions() { return positions; } }拿到坐标后,注意PDF的坐标系原点在左下角,而很多设计工具的原点在左上角,换算的时候要小心。页面高度减去Y坐标才是从上往下的距离。
4. 指定位置插入图片的完整实现
4.1 图片插入的基本API
iText 7插入图片用Image对象配合Canvas或者Document。如果是往已有PDF的指定位置插图片,用PdfCanvas更直接。
PdfDocument pdfDoc = new PdfDocument(new PdfReader("input.pdf"), new PdfWriter("output.pdf")); PdfPage page = pdfDoc.getPage(1); ImageData imageData = ImageDataFactory.create("seal.png"); Image image = new Image(imageData); // 设置位置和大小 image.setFixedPosition(1, x, y); image.scaleToFit(width, height); // 通过Document添加 Document doc = new Document(pdfDoc); doc.add(image); doc.close();setFixedPosition的第一个参数是页码,后面是X和Y坐标。scaleToFit会保持宽高比缩放到指定范围内,避免图片变形。
4.2 坐标计算与对齐
插入图片最麻烦的是坐标计算。假设你要在“盖章处”三个字右边插入一个印章图片,你得先拿到“盖章处”的坐标,然后根据文字宽度算出右边的位置。
float[] pos = positions.get(0); float textWidth = ...; // 通过字体和字号计算 float imageX = pos[0] + textWidth + 5; // 右边留5个单位的间距 float imageY = pos[1] - 10; // 稍微下移,让图片和文字基线对齐文字宽度的计算可以用font.getWidth(text, fontSize)。如果拿不到精确宽度,可以估算:中文字符宽度约等于字号,英文字符约等于字号的一半。
实操心得:插入图片前先用一个透明矩形框标出位置,生成PDF后打开看看位置对不对,确认无误再换成真实图片。这个调试方法能省很多来回改坐标的时间。
4.3 图片透明度和旋转
印章图片经常需要半透明效果,iText支持设置透明度。
PdfCanvas canvas = new PdfCanvas(page); PdfExtGState gs = new PdfExtGState(); gs.setFillOpacity(0.8f); canvas.setExtGState(gs); canvas.addImageFittedIntoRectangle(imageData, rect, false);旋转用image.setRotationAngle(Math.toRadians(45)),注意参数是弧度不是角度。旋转的中心点默认是图片左下角,如果需要绕中心旋转,得自己调整坐标偏移。
5. 完整实操流程与代码拆解
5.1 项目结构
我一般这样组织代码:
src/main/java/ com/example/pdf/ PdfTextReplacer.java // 文本替换 PdfImageInserter.java // 图片插入 PdfPositionFinder.java // 坐标定位 PdfMain.java // 入口 src/main/resources/ fonts/simsun.ttf templates/contract.pdf images/seal.png5.2 文本替换的完整实现
以覆盖式替换为例,完整流程是这样的:
public class PdfTextReplacer { public void replaceText(String src, String dest, Map<String, String> replacements) throws IOException { PdfDocument pdfDoc = new PdfDocument( new PdfReader(src), new PdfWriter(dest)); PdfFont font = PdfFontFactory.createFont( "fonts/simsun.ttf", PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); for (int i = 1; i <= pdfDoc.getNumberOfPages(); i++) { PdfPage page = pdfDoc.getPage(i); PdfCanvas canvas = new PdfCanvas(page); for (Map.Entry<String, String> entry : replacements.entrySet()) { String oldText = entry.getKey(); String newText = entry.getValue(); List<float[]> positions = findTextPosition(page, oldText); for (float[] pos : positions) { // 用白色矩形覆盖原文 canvas.setFillColor(Color.WHITE); canvas.rectangle(pos[0] - 2, pos[1] - 3, oldText.length() * 12 + 4, 18); canvas.fill(); // 写入新文字 canvas.beginText(); canvas.setFontAndSize(font, 12); canvas.moveText(pos[0], pos[1]); canvas.showText(newText); canvas.endText(); } } } pdfDoc.close(); } private List<float[]> findTextPosition(PdfPage page, String target) { TextPositionListener listener = new TextPositionListener(target); PdfCanvasProcessor processor = new PdfCanvasProcessor(listener); processor.processPageContent(page); return listener.getPositions(); } }这段代码有几个细节要注意。覆盖矩形的宽度用oldText.length() * 12是粗略估算,中英文混排时不准。更精确的做法是用原字体计算宽度,但覆盖式替换本来就不追求像素级精确,留点余量反而更安全。新文字的字号要和原文一致,否则视觉上会突兀。
5.3 图片插入的完整实现
public class PdfImageInserter { public void insertImage(String src, String dest, String imagePath, int pageNum, float x, float y, float width, float height) throws IOException { PdfDocument pdfDoc = new PdfDocument( new PdfReader(src), new PdfWriter(dest)); PdfPage page = pdfDoc.getPage(pageNum); ImageData imageData = ImageDataFactory.create(imagePath); PdfCanvas canvas = new PdfCanvas(page); Rectangle rect = new Rectangle(x, y, width, height); canvas.addImageFittedIntoRectangle(imageData, rect, false); pdfDoc.close(); } }addImageFittedIntoRectangle的最后一个参数false表示不保持宽高比,如果设为true则会等比缩放并居中。印章图片一般用false,因为印章通常是正方形,指定正方形区域不会变形。
5.4 参数计算实例
假设A4纸(595 x 842点),要在右下角盖一个直径80点的圆形印章,距离右边距50点,距离下边距80点。
印章左下角坐标:
- X = 595 - 50 - 80 = 465
- Y = 80
如果印章要盖在“负责人签字:”这行字的右边,先找到这行字的坐标,假设是(100, 200),文字宽度约72点(6个中文字符 x 12点),那么印章位置:
- X = 100 + 72 + 10 = 182
- Y = 200 - 20 = 180(下移让印章中心和文字对齐)
这些数值不是拍脑袋来的,是根据字体度量和视觉习惯算出来的。实际调试时可以用PDF阅读器的测量工具验证。
6. 常见问题与排查技巧实录
6.1 中文显示为方框或乱码
这是最高频的问题。原因通常是字体没有正确嵌入,或者编码方式不对。排查步骤:
- 确认字体文件路径正确,文件本身没有损坏
- 确认使用
IDENTITY_H编码 - 确认
EmbeddingStrategy设为PREFER_EMBEDDED或FORCE_EMBEDDED - 用提取工具检查生成PDF的文字是否可提取
如果还是乱码,试试换一个字体文件。有些精简版的TTF字体缺少必要的字符映射表,换用完整的宋体或黑体通常能解决。
6.2 替换后的文字位置偏移
覆盖式替换时,新文字的基线位置如果和原文不一致,看起来就会错位。解决办法是记录原文的基线坐标,新文字用相同的基线。如果原文有旋转或缩放变换,还需要把变换矩阵应用到新文字上。
Matrix matrix = info.getTextMatrix(); canvas.setTextMatrix(matrix);6.3 图片插入后不显示
检查这几点:图片格式是否支持(PNG、JPG、GIF都支持,WebP不支持)、坐标是否在页面范围内、图片数据是否成功加载。有时候图片路径对了但文件是空的,也会导致不显示。
6.4 处理大文件时内存溢出
iText默认会把整个PDF加载到内存。处理几百页的大文件时,可以用PdfReader的随机访问模式,配合PdfDocument的flush机制。
PdfReader reader = new PdfReader(src); reader.setUnethicalReading(true); // 处理加密PDF PdfDocument pdfDoc = new PdfDocument(reader, new PdfWriter(dest)); // 处理完一页后 pdfDoc.flush();flush()会把已处理的内容写入磁盘并释放内存,但注意flush之后不能再修改之前的页面。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 中文乱码 | 字体未嵌入或编码错误 | 用IDENTITY_H编码,强制嵌入字体 |
| 文字位置偏移 | 基线坐标不一致 | 记录原文基线,应用相同变换矩阵 |
| 图片不显示 | 格式不支持或坐标越界 | 换PNG/JPG,检查坐标范围 |
| 内存溢出 | 大文件全量加载 | 分页处理,及时flush |
| 替换后原文还在 | 覆盖矩形太小 | 增大覆盖范围,或改用内容流替换 |
| 生成的PDF打不开 | 文档未正确关闭 | 确保close()被调用,用try-with-resources |
避坑技巧:处理前先备份原始PDF。iText的修改是破坏性的,一旦写坏很难恢复。我习惯在代码里加一步自动备份,文件名加时间戳。
7. 进阶技巧与性能优化
7.1 批量处理的并行化
如果要对几百个PDF做相同处理,单线程太慢。可以用线程池并行,但要注意每个线程用独立的PdfDocument实例,iText的对象不是线程安全的。
ExecutorService executor = Executors.newFixedThreadPool(4); List<Future<?>> futures = new ArrayList<>(); for (String file : files) { futures.add(executor.submit(() -> processOne(file))); } for (Future<?> f : futures) { f.get(); } executor.shutdown();线程数不要超过CPU核心数,PDF处理是IO和CPU混合型任务,线程太多反而会因为磁盘竞争变慢。
7.2 模板化生成 vs 修改已有PDF
如果业务允许,我更推荐模板化生成而不是修改已有PDF。用iText的Document和Paragraph从头构建,布局可控,不用担心字体子集化和坐标偏移。修改已有PDF只适合那些格式固定、无法重新生成的场景,比如对方提供的标准合同。
7.3 字体子集化的取舍
嵌入完整字体文件会让PDF变大,但兼容性最好。子集化只嵌入用到的字符,文件小但偶尔会出现字符缺失。我的经验是:如果PDF要长期存档或者跨平台分发,用完整嵌入;如果只是内部流转,子集化够用。
// 完整嵌入 PdfFontFactory.EmbeddingStrategy.FORCE_EMBEDDED // 子集化 PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED7.4 处理加密PDF
有些PDF有权限密码,iText读取时会抛异常。如果只是限制编辑不限制阅读,可以用setUnethicalReading(true)绕过。但要注意,这个操作可能涉及合规问题,确保你有权处理这些文档。
PdfReader reader = new PdfReader(src); reader.setUnethicalReading(true);我在实际项目里遇到最多的情况是:PDF是扫描件,文字提取为空,客户却要求替换里面的文字。这种时候只能老老实实告诉对方,要么提供可编辑的原始文件,要么走OCR识别后重新生成。技术不是万能的,提前和需求方对齐PDF的类型,能省掉大量返工。
最后分享一个小技巧:调试坐标时,可以临时把插入的图片换成一个红色半透明矩形,生成后直接看位置对不对,比反复猜坐标高效得多。等位置确认了,再换成正式图片。这个习惯帮我省下了至少一半的调试时间。