
最开始接触到“用iText读取PDF文件提取对应字符串”这个需求是在做一个合同管理系统的时候。客户要求把所有PDF合同的关键字段——比如合同编号、甲方名称、金额——自动抓出来存进数据库。当时第一反应是“PDF读取应该不难吧”结果真上手才明白这里面水很深。不说复杂的排版解析光是搞定中文乱码、生僻字、扫描件这几座大山就能让一个成熟的Java程序员折腾好几天。这篇文章我就把用iText做文本提取的完整思路、实操代码和踩坑记录整理出来希望能帮你少走弯路。1. 为什么选iText做PDF文本提取1.1 PDF里的“文字”到底是怎么存的先用大白话解释一下PDF的文本存储机制不然后面很多坑你没法理解。PDF文件里的文字和我们平时用Word看到的“字符”很不一样——PDF更像一张拍好的“数字底片”。你在PDF里看到的一个“甲”字底层可能是几十个绘图指令的叠加先定义一个字体对象再指定字号大小然后在某个坐标点上把这个字形的轮廓“画”上去。这意味着PDF本身根本没有“段落”“行”“字符串”的概念只有“绘制文本对象”和坐标。所以任何提取PDF文本的工具本质上做的工作是解析PDF内部的内容流找到那些Tj、TJ、、之类的文本绘制运算符把夹在里面的字节解码成可读的字符串。iText干的就是这件事而且它在处理字节级解码、字体映射、坐标计算这些底层细节上做得相当成熟这就是我选择它的核心理由。1.2 iText、PDFBox、Tika三选一在Java生态里做PDF文本提取有三个主流方案iText、Apache PDFBox和Apache Tika。通过一次实际的选型对比你会发现它们的差异非常大对比维度iTextPDFBoxApache Tika文本提取能力强支持自定义策略中可提取原始文本强封装了PDFBox等中文和生僻字支持好字体映射机制完善一般部分字体需额外处理继承PDFBox能力低级API开放度高可操作PdfReader、内容流高可操作PDDocument、COSStream低偏向“开箱即用”定位差异除了读取还能创建、修改PDF偏重读取和简单操作多格式统一入口上手难度中等需理解Document概念中等低最终选择建议✅ 推荐作为主方案适合轻量场景和二次封装适合“不管什么文档都抽文本”的场景iText最突出的优势是它对“提取”动作有完整的策略控制。你可以调整提取时的字体解析方式、处理不可见文本、甚至自定义坐标范围内的内容提取。PDFBox虽然免费且社区也大但在处理某些特殊编码字体时提取出来的中文偶尔会变成“□□”或乱码Tika则更像一个“瑞士军刀”适合快速摸清文件底细但真要精细控制还是得回到PDFBox或iText层面。所以我的建议是如果项目预期不止一次要和PDF打交道一定要选iText别省这个学习成本。2. 环境准备与最基础的读取流程2.1 引入iText依赖现在主流的iText版本是iText 7.x和iText 5.x两个版本在API上有较大差异。如果你维护的是老项目可能还在用com.itextpdf:itextpdf:5.5.13.3如果是新项目建议直接用com.itextpdf:itext7-core。注意一个细节iText 7把不同功能拆成了多个模块文本提取只需要kernel和io两个核心模块但如果后续要做PDF生成还需要layout模块。!-- iText 7.x 推荐 -- dependency groupIdcom.itextpdf/groupId artifactIditext7-core/artifactId version7.2.5/version typepom/type /dependency !-- 如果只想用最小依赖也可以拆开 -- dependency groupIdcom.itextpdf/groupId artifactIdkernel/artifactId version7.2.5/version /dependency dependency groupIdcom.itextpdf/groupId artifactIdio/artifactId version7.2.5/version /dependency注意iText在商业使用上有AGPL和商业版的双重授权问题。纯内部工具或开源项目使用AGPL问题不大但如果是在闭源商业系统里面向外部用户提供服务一定要评估商业授权别在这上面吃法律亏。这个不是吓唬人iText公司近年对License的审查越来越严格。2.2 第一个能跑的读取示例环境配好之后写一个最基础的读取程序。目标很简单把PDF文件里的所有文本一次性撸出来。import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; import java.io.IOException; public class SimplePdfReader { public static void main(String[] args) { String filePath /tmp/example.pdf; try (PdfDocument pdfDoc new PdfDocument(new PdfReader(filePath))) { int pageCount pdfDoc.getNumberOfPages(); StringBuilder content new StringBuilder(); for (int pageNum 1; pageNum pageCount; pageNum) { // 按页提取文本 String pageText PdfTextExtractor.getTextFromPage(pdfDoc.getPage(pageNum)); content.append(pageText); content.append(\n--- 第 ).append(pageNum).append( 页结束 ---\n); } System.out.println(content.toString()); } catch (IOException e) { e.printStackTrace(); } } }这段代码经历了“能跑起来”到“能干活”的转变关键就在于理解PdfTextExtractor.getTextFromPage的返回逻辑。它返回的是该页所有文本对象的按读取顺序拼接结果这个顺序通常是从上到下、从左到右但遇到多栏排版或表格这种复杂结构时它会直接按内容流里的对象顺序输出结果可能和你看到的排版不一致。所以第一次跑出来发现文本顺序怪怪的不要慌这是正常的后续我再说怎么处理。2.3 核心API逐个拆解iText 7的文本提取API核心就三个PdfReader、PdfDocument和PdfTextExtractor。PdfReader负责最底层的文件解析它做两件事一是读取PDF的物理结构对象树、交叉引用表、加密字典等二是验证文件是否合法。构造PdfReader的时候可以传入文件路径、InputStream或byte[]。特别注意如果你传入InputStream记得用完让它自己关或者手动关否则文件句柄会泄漏。更推荐用byte[]的方式一次读完放内存对后续复用友好。PdfDocument是PDF文件的逻辑模型你可以理解成“整个文档的代理”。通过它可以拿页数、拿页面、拿文件级元数据。注意PdfDocument实现了Closeable接口务必用try-with-resources或者finally块确保关闭否则进程可能无法正常结束。PdfTextExtractor是文本提取的核心工具类它内部有一个策略模式的设计。getTextFromPage默认使用SimpleTextExtractionStrategy这个策略会把页面Content Stream里的文本对象拆出来并拼接到一起。如果想要精确控制提取行为比如只提取某一块区域、忽略不可见文本、或者保留文字相对于页面的坐标就需要自己写一个TextExtractionStrategy的实现或者用已有的LocationTextExtractionStrategy。3. 从“能读”到“读得准”字符串提取的完整实操3.1 多页PDF遍历与按页提取遇到几十页甚至几百页的大文件逐页遍历是最直观的做法。但实际做业务时客户往往说“我要提取整个PDF的字符串”这里有个容易犯的错误把所有页的文本简单拼在一起就完了。如果你要统计某个字符串的出现次数或者按章节定位内容按页处理比一次性处理要灵活得多。import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfPage; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; import com.itextpdf.kernel.pdf.canvas.parser.listener.LocationTextExtractionStrategy; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; public class PageTextExtractor { public static MapInteger, String extractByPage(byte[] pdfBytes) throws IOException { MapInteger, String result new HashMap(); try (PdfDocument pdfDoc new PdfDocument(new PdfReader(pdfBytes))) { int pages pdfDoc.getNumberOfPages(); for (int i 1; i pages; i) { PdfPage page pdfDoc.getPage(i); // iText的页码从1开始 // 使用LocationTextExtractionStrategy能更好保留排版顺序 LocationTextExtractionStrategy strategy new LocationTextExtractionStrategy(); String pageText PdfTextExtractor.getTextFromPage(page, strategy); result.put(i, pageText); } } return result; } public static void main(String[] args) throws IOException { byte[] fileBytes Files.readAllBytes(Paths.get(/tmp/big-file.pdf)); MapInteger, String pageMap extractByPage(fileBytes); pageMap.forEach((pageNum, text) - { System.out.println( Page pageNum ); System.out.println(text); }); } }这里有个小坑iText的getPage()页码和人类习惯一样从1开始计数不是从0开始。很多人第一次写循环时这里就翻车了。另外如果PDF文件很大一次读取所有页可能会导致内存压力这时候可以改成流式处理每解析完一页就处理掉然后释放引用。不过iText 7 Page对象内部有缓存一页的处理结果不会整个占住内存真正要注意的是byte[]本身的体积超大文件建议用文件路径方式让iText内部用RandomAccessFile按需读取。3.2 提取结果清洗去掉换行、去重、合并直接从PDF提取出来的文本通常很“脏”。原因很简单PDF排版时每个换行、每个空格都是视觉所必需的但并不是你逻辑上想要的。最常见的三种脏数据情况每一行末尾会被塞一个换行符因为PDF里的每一行都是独立的绘制动作单词之间会出现莫名其妙的空格特别是两端对齐的文本字母之间会插入额外的空隙多栏布局会把左边栏底部的内容和右边栏顶部的内容强行拼接读取顺序完全错位。针对这些情况我习惯写一套清洗管线。核心思路是“先合并、再切分”不要试图一步到位。public class TextCleaner { /** * 清洗PDF提取出来的原始文本。 * 步骤去掉无意义空白、规范化换行、合并断词、去掉孤立的页眉页脚。 */ public static String clean(String rawText) { if (rawText null || rawText.isEmpty()) { return rawText; } // 1. 统一换行符 String cleaned rawText.replace(\r\n, \n).replace(\r, \n); // 2. 把连字符断词合并例如 exam-\nple - example // 注意中文内容不存在断词问题这个操作要在纯英文场景才做 cleaned cleaned.replaceAll(-\\n, ); // 3. 去掉行末多余的空格每行末尾的空格是排版留下的 cleaned cleaned.replaceAll([ \\t]\\n, \n); // 4. 多个连续空行压缩为一行 cleaned cleaned.replaceAll(\\n{3,}, \n\n); // 5. 如果需要可以把所有换行替换为空格按句子重新分段 // 我这里先保留换行后续按需处理 return cleaned; } }这套清洗思路经历了大量生成型PDF的测试验证。具体来说第2步的断词合并只适用于英文中文文本如果含有“-”这种正常字符会被误伤所以建议对不同语言的PDF走不同的清洗分支第3步很关键很多PDF在右边界会做右对齐导致每行末尾插入大量空格直接做成正则替换能把这种噪音降到零第5步根据你的提取目标来定如果只是搜索“合同编号”可以直接把全文换成空格分隔的“词袋”但如果要做段落级操作就要保留换行结构。3.3 用正则匹配目标字符串清洗干净之后提取指定字符串就变成了一个纯正则匹配问题。这里分享一个我实际用在合同系统里的模式先用锚点词定位再截取上下文。比如要从一份合同里提取“甲方名称某某公司”我不能直接搜“甲方名称”因为PDF排版可能导致“甲方名称”这4个字被拆成“甲方名 称”或者中间插入空格。这时候要先做一步“模糊化处理”把提取结果里的所有空白字符统一替换成一个占位符然后正则里也用一个灵活匹配的模式。import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class StringExtractor { /** * 在文本中查找目标字符串返回所有匹配。 * 自定义模糊因子允许空白字符存在。 */ public static ListString extractWithFlexibleMatch(String content, String target) { ListString results new ArrayList(); // 把原文里的所有空白字符转为【一个空格】 String normalized content.replaceAll(\\s, ); // 目标字符串里的每个字符之间允许0个或多个空白 String[] chars target.split(); StringBuilder patternBuilder new StringBuilder(); for (String c : chars) { if (c.isEmpty()) { continue; } patternBuilder.append(Pattern.quote(c)); // 对特殊字符转义 patternBuilder.append(\\s*); // 允许空白 } Pattern pattern Pattern.compile(patternBuilder.toString()); Matcher matcher pattern.matcher(normalized); while (matcher.find()) { results.add(matcher.group()); } return results; } public static void main(String[] args) { String pdfText 合同编号HT20250001 甲方名称XX科技有限公司 乙方名称XX软件工作室 签订日期2025年3月20日 ; ListString hits extractWithFlexibleMatch(pdfText, 甲方名称); System.out.println(匹配结果数: hits.size()); // 实际业务提取甲方名称后面的内容 for (String hit : hits) { int idx pdfText.indexOf(hit); String rest pdfText.substring(idx hit.length()).trim(); String[] lines rest.split(\n); System.out.println(甲方名称 - lines[0]); } } }这个思路的精髓在于“模式构造”不是用现成的正则硬搜而是把目标字符串拆成char数组再在每个字符之间插入\s*让iText提取出的各种幽灵空格都能被容忍。实测下来这个方法对付90%的PDF排版噪音都有效。3.4 表格场景下的字符串提取如果你的PDF里有表格iText默认策略提取出来的文本顺序会非常扭曲。因为表格的每个单元格在内容流里可能是分开记录的而SimpleTextExtractionStrategy只是按内容流顺序拼接结果就是“表格内容横七竖八地混在一起”。处理表格数据我在实操中总结了一套实用方法。第一步先用LocationTextExtractionStrategy拿到每个文本块的位置信息。LocationTextExtractionStrategy会为每个文本块记录它的矩形边界有了坐标就能判断哪些文本属于同一行。第二步按Y坐标分组在PDF的坐标系里Y值相同或非常接近的文本片段大概率在同一行。注意PDF坐标系的原点在页面左下角Y轴向上所以Y值越大代表越靠近页面顶部。第三步同一行内部再按X坐标排序X值越小的越靠左。这样就能还原出表格的一行。import com.itextpdf.kernel.geom.Rectangle; import com.itextpdf.kernel.pdf.canvas.parser.EventType; import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData; import com.itextpdf.kernel.pdf.canvas.parser.data.ImageRenderInfo; import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo; import com.itextpdf.kernel.pdf.canvas.parser.listener.LocationTextExtractionStrategy; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.TreeMap; public class TableAwareStrategy extends LocationTextExtractionStrategy { // 用 Y 坐标作为 key同一行文本块放到同一个列表 private final MapFloat, ListTextRenderInfo textLines new TreeMap(Comparator.reverseOrder()); Override public void eventOccurred(IEventData data, EventType type) { if (type EventType.RENDER_TEXT) { TextRenderInfo renderInfo (TextRenderInfo) data; Rectangle rect renderInfo.getBaseline().getBoundingRectangle(); // 容差按实际字体大小调整取 2 个点即可 float yKey Math.round(rect.getY() / 2f) * 2f; textLines.computeIfAbsent(yKey, k - new ArrayList()).add(renderInfo); } super.eventOccurred(data, type); } Override public String getResultantText() { StringBuilder builder new StringBuilder(); for (ListTextRenderInfo line : textLines.values()) { line.sort(Comparator.comparingDouble(r - r.getBaseline().getBoundingRectangle().getX())); for (TextRenderInfo r : line) { builder.append(r.getText()); } builder.append(\n); } return builder.toString(); } }这段代码的核心在于覆盖eventOccurred方法自己接管文本渲染事件不再依赖iText默认的行拼接逻辑。通过把Y坐标量化到某个精度等级这里是2点就能把同一行的文本块聚到一起。实测下来对这种“带框线的标准表格”效果非常理想基本能做到“所见即所得”。4. 踩坑实录乱码、生僻字、扫描件与加密PDF4.1 中文乱码和生僻字问题用iText提取中文PDF时最经典的坑就是拿到一串“?????”或者“锟斤拷”。原因通常是PDF里嵌入了自定义字体子集而模块解码时找不到对应的CMap映射表或者字体子集在编码时用的是自定义编码iText按默认编码去还原自然对不上。按照我的排查经验先分清楚文件类型再动手文件特征可能原因对策能用浏览器正常查看文字字体子集嵌入iText解析字体映射失败换用PdfDocumentProperties校验看Font相关对象提取出来是乱码但复制到记事本正常字符编码映射被字体子集扰动尝试用PDFBox交叉验证若PDFBox正常则考虑iText版本问题生僻字提取后变方框字体子集没包含该字形轮廓从PDFBox或底层字体字典里强制提取CMap整体乱码但英文正常中文字体编码不一致检查是否嵌入了非标准编码的CID字体iText 7对标准中文字体如SimSun、微软雅黑的处理已经比较成熟普通文档基本不会乱码。真正头疼的是那种“印刷厂出的PDF”它们嵌的不是常见字体而是专用字体子集。这种情况下我建议采取“备选方案”如果只是几个生僻字要提取可以直接用底层API遍历字体字典里的ToUnicode CMap把字形码映射成Unicode。iText的PdfFont对象有个方法叫getGlyphList()能拿到内部字形映射。如果整个文档都乱码或者字体信息完全加密那就别挣扎了直接用OCR方案。推荐把PDF按页转成高清图片再用PaddleOCR或Tesseract识别。虽然速度和准确率都比文本提取差但至少能“把数据弄出来”。遇到生僻字还有一个实用技巧别只依赖文本层用PDF页面渲染成图片后局部放大人眼判断或者再用OCR兜底。因为生僻字的字形轮廓在PDF里一定有只是文本层不一定能正确解码而已。这里我踩过一个具体的坑一份中文PDF提取出来的内容里所有“章”字都变成了“□”。排查后发现该PDF嵌入的字体的ToUnicode CMap范围只覆盖了部分Unicode码点而“章”字正好不在范围内。当时尝试了不下5种方案最后还是通过读取页面渲染后的图片用OCR识别才拿到正确内容。所以如果你遇到类似问题不要在纯文本层面死磕要承认“文本层已经损坏”这件事及时切换到渲染OCR路线。4.2 扫描件根本没文字层怎么办扫描件这个坑容易踩但实际上技术上好判断把一个PDF用iText打开提取出来的文本如果是空字符串大概率就是扫描件。还有一种情况提取出来有少量杂乱英文比如“Form1”“Signature”之类的这是OCR软件自动加的识别层不是真正的PDF文本。扫描件的处理思路完全不一样因为它本质是“图片编成的PDF”。常规路线是用iText的PDFRenderer或者其他工具把每一页渲染成高分辨率图片。对图片做预处理灰度化、二值化、去燥点提升OCR准确率。用OCR引擎执行识别输出结构化的文本。这里涉及一个常见操作用iText提取页面尺寸然后按600dpi或者1200dpi渲染成BufferedImage交给OCR。不建议用300dpi以下的分辨率小字号特别容易识别成乱码。如果你处理的图片质量还不错PaddleOCR的中文识别精度远超Tesseract尤其是那种带表格线的合同效果差距很明显。import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfPage; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.rendering.PdfRenderer; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; public class PdfToImageForOcr { public static void renderPageToImage(String pdfPath, int pageIndex, String outputImagePath) throws IOException { try (PdfDocument pdfDoc new PdfDocument(new PdfReader(pdfPath))) { PdfPage page pdfDoc.getPage(pageIndex); // 创建渲染器600dpi基本够用 PdfRenderer renderer new PdfRenderer(page); float scale 600f / 72f; // 从 72dpi 换算到 600dpi BufferedImage image renderer.renderImage(scale, PdfRenderer.RENDER_MODE_READING_ORDER); ImageIO.write(image, png, new File(outputImagePath)); } } public static void main(String[] args) throws IOException { renderPageToImage(/tmp/scanned.pdf, 1, /tmp/page-1.png); } }注意PdfRenderer类在iText 7的pdf-renderer模块里需要额外引入itext7-pdfrenderer依赖。这个模块不是核心包自带的不引入会直接报ClassNotFoundException。4.3 加密PDF、超大PDF的处理边界加密PDF这块我要给你提个醒iText能处理的加密PDF范围非常有限。对于“只允许被查看但禁止复制、打印、编辑”的那一类PDFiText在提取文本的时候会直接抛异常提示“This document is encrypted”。对于这种文件技术上的“标准答案”是先确认有没有密码有密码就用new PdfReader(filePath, passwordBytes)去打开没密码就只能走“破解权限”路线但那个属于打擦边球合规风险很高我不建议在博文里展开。有一说一如果PDF加密真的挡在前面你首先要做的不是写代码绕过而是跟业务方确认文件来源。很多时候加密只是ERP系统自动加的根本不需要你去绕过它找管理员要个无密版本就完事了。超大PDF的处理边界同样值得关注。我处理过一份1000多页的PDF用iText逐页提取内存稳定在300MB左右能吃得下。但如果你试图一次性Files.readAllBytes()读入一个500MB的PDFJVM的堆大概率会跳楼OOM来得非常快。处理超大PDF的正确姿势是优先用文件路径创建PdfReader让iText内部按需RandomAccessFile读取。分段处理比如每50页一轮提取完把结果存好再继续。如果PDF包含大量图片iText解析时可能触发图片对象初始化非常耗内存。建议先尝试跳过图片渲染只处理内容流文字。下面是一段处理超大PDF的分页提取框架实测下来可以稳定处理几百MB级别的文件import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; public class LargePdfReader { public static void extractLargePdf(String filePath, int batchSize) throws IOException { try (PdfDocument pdfDoc new PdfDocument(new PdfReader(filePath))) { int totalPages pdfDoc.getNumberOfPages(); for (int startPage 1; startPage totalPages; startPage batchSize) { int endPage Math.min(startPage batchSize - 1, totalPages); StringBuilder batchText new StringBuilder(); for (int p startPage; p endPage; p) { String pageText PdfTextExtractor.getTextFromPage(pdfDoc.getPage(p)); batchText.append(pageText); } saveBatchToFile(startPage, endPage, batchText.toString()); } } } private static void saveBatchToFile(int startPage, int endPage, String text) throws IOException { String fileName pdf_ startPage _to_ endPage .txt; Files.writeString(Paths.get(fileName), text); } public static void main(String[] args) throws IOException { extractLargePdf(/tmp/huge.pdf, 50); } }如果文件实在太大连PdfReader都打不开或者打开后页码信息加载极慢那就考虑另一个思路用qpdf或mutool先把PDF拆分再对分片文件做iText提取。工具不在Java生态内但效果非常稳定。5. 高级定位技巧按坐标提取特定区域文本5.1 为什么需要坐标提取到了项目后期你会发现“把全文提取出来再正则匹配”还是很笨重。比如你要提取一份发票里的所有行项目但发票版式复杂“金额”“税额”这些词大概率同时出现在多个地方正则匹配很容易抓到错误位置。更优雅的方案是按坐标圈定区域只提取特定矩形框里的文本。它的逻辑是既然PDF底层就是坐标驱动绘制那我直接告诉iText“我只关心这些坐标范围里的文字”提取结果自然干净很多。这个方案特别适合处理两类PDF一类是样式高度固定的电子表单比如银行流水单、发票、报销单另一类是合同或标书里的关键信息通常集中在固定的题头区域。5.2 用自定义RenderFilter实现区域过滤iText 7里要按坐标提取核心是利用PdfTextExtractor的一个重载方法传入一个RenderFilter数组。RenderFilter决定了哪些文本块会被保留、哪些会被忽略。import com.itextpdf.kernel.geom.Rectangle; import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfPage; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; import com.itextpdf.kernel.pdf.canvas.parser.filter.TextRegionEventFilter; import com.itextpdf.kernel.pdf.canvas.parser.listener.FilteredTextEventListener; import com.itextpdf.kernel.pdf.canvas.parser.listener.LocationTextExtractionStrategy; import java.io.IOException; public class RegionTextExtractor { public static String extractTextInRegion(String filePath, int pageNum, float x, float y, float width, float height) throws IOException { try (PdfDocument pdfDoc new PdfDocument(new PdfReader(filePath))) { PdfPage page pdfDoc.getPage(pageNum); // 定义目标区域 Rectangle region new Rectangle(x, y, width, height); // 创建区域过滤器 TextRegionEventFilter regionFilter new TextRegionEventFilter(region); // 组合策略先过滤再提取 FilteredTextEventListener filteredListener new FilteredTextEventListener(new LocationTextExtractionStrategy(), regionFilter); return PdfTextExtractor.getTextFromPage(page, filteredListener); } } public static void main(String[] args) throws IOException { // 假设第1页要提取左上角 200x100 点范围内的内容 String text extractTextInRegion(/tmp/invoice.pdf, 1, 50, 700, 200, 100); System.out.println(text); } }坐标说明PDF的坐标原点在页面左下角X向右增大Y向上增大。这里Region的y参数是区域左下角的Y坐标如果你习惯了“从页面左上角开始算”需要做一个换算pdfY pageHeight - topOffsetY - height。要拿页面高度可以用pdfDoc.getPage(pageNum).getPageSize().getHeight()。5.3 实测固定区域提取的经验值根据我处理大量PDF的真实经验使用区域提取时有三个要点坐标框别太抠。如果你圈定的区域比实际文本块小提取结果会缺字。建议在视觉上稍微“外扩”几个像素让矩形框完整覆盖目标内容。如果区域内的文本是多列布局提取结果可能按内容流顺序拼出来不会自动按视觉顺序排序。这时候可以配合我前面写的TableAwareStrategy一起用。在固定模板场景下不同PDF文件的页面大小可能不一样。如果供应商发来的合同全是A4纸还好如果混合了A4和A3坐标区域会整体偏移必须动态获取页面尺寸做归一化计算。举个例子在发票识别项目里我用区域提取替代了全文正则匹配准确率从85%直接升到97%。原因很简单在几千字的全文里找“金额”两个字有太多种可能的干扰但在“右上角第2行”这个明确区域里找“金额”几乎不会出错。如果你面对的是模版固定的业务文档不妨较早在方案里加入坐标提取这条技术路线。6. 一些容易忽略的小细节6.1 PdfReader的close顺序会影响文件占用iText 7的PdfDocument和PdfReader都有close方法官方文档建议先关PdfDocument再关PdfReader。实际上PdfDocument.close()会尝试把文件句柄释放掉如果你先关PdfReader再关PdfDocument偶尔会报“Attempt to use a closed document”的IllegalStateException。用try-with-resources把PdfDocument当作唯一需要关闭的资源就够了PdfReader不需要单独关闭。6.2 提取超长字符串时的内存优化一个PDF段落可能非常长比如一份几百页的法律意见书提取出来的全文可能有几十万字符。如果你只是提取其中几个关键词没必要把全文一次性装进StringBuffer再匹配。建议用流式处理每提取一页立即做正则匹配命中就把结果保存然后清空该页的字符串引用让GC把内存收走。6.3 版本升级带来的API差异如果你的老项目用的是iText 5.x代码里常见的是com.itextpdf.text.pdf.PdfReader和com.itextpdf.text.pdf.parser.PdfTextExtractor。升级到iText 7后包名变成了com.itextpdf.kernel.pdf.*和com.itextpdf.kernel.pdf.canvas.parser.*API的调用方式也有变化。比如iText 5的PdfTextExtractor.getTextFromPage(reader, pageNum)在iText 7里变成了PdfTextExtractor.getTextFromPage(pdfPage)同时PdfReader构造方式不同iText 7的PdfDocument把文件和页面对象分离了理解了这个模型踩坑就少一半。6.4 实际项目里的“字符串提取”到底是什么最后聊点业务层面的体会。很多刚接触这个需求的同事以为“提取字符串”就是把整个PDF变成一个大字符串。但实际项目中真正有价值的是“从PDF里提取出特定的业务字段”。这个区别决定了技术选型和实现方案如果只是做全文搜索iText默认策略就够用。如果要统计关键词出现次数清洗工作比提取工作更耗时。如果要把结构化字段落到库里坐标提取和模板配置是最省力的路线。我经手的系统里统一做了一个“PDF转结构化JSON”的管线先用iText按区域提取关键字段再用OCR兜底处理扫描件最后所有结果进数据库前端直接渲染成表单。这套方案上线后几乎没有返工。7. 实际项目中的完整提取管线7.1 一个可复用的“加载-校验-提取-清洗-落库”框架纸上谈兵聊完了不如直接把我在生产环境用的一套管线分享出来。这个管线的核心目标不只是“提取文本”而是“稳定地从一堆杂乱PDF里捞出关键字段”。整体流程五步加载文件、校验可提取性、按页提取、清洗、字段提取落库。import com.itextpdf.kernel.exceptions.PdfException; import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; import java.io.IOException; import java.util.regex.Matcher; import java.util.regex.Pattern; public class PdfPipeline { public static class ExtractionResult { public boolean success; public String errorMessage; public String contractNo; public String partyA; } public static ExtractionResult processContract(String filePath) { ExtractionResult result new ExtractionResult(); try (PdfDocument pdfDoc new PdfDocument(new PdfReader(filePath))) { StringBuilder fullText new StringBuilder(); int pageCount pdfDoc.getNumberOfPages(); // 1. 逐页提取 for (int i 1; i pageCount; i) { fullText.append(PdfTextExtractor.getTextFromPage(pdfDoc.getPage(i))); } // 2. 基本校验是否提取到了文本 if (fullText.toString().trim().isEmpty()) { result.errorMessage 可能为扫描件或加密文档; return result; } // 3. 清洗 String cleaned TextCleaner.clean(fullText.toString()); // 4. 正则在清洗后的文本里找字段 result.contractNo extractFirst(cleaned, 合同编号[:\\s]*([A-Z0-9\\-])); result.partyA extractFirst(cleaned, 甲方名称[:\\s]*(.)); result.success true; } catch (PdfException | IOException e) { result.errorMessage e.getMessage(); } return result; } private static String extractFirst(String text, String regex) { Pattern pattern Pattern.compile(regex); Matcher matcher pattern.matcher(text); if (matcher.find()) { return matcher.group(1).trim(); } return ; } public static void main(String[] args) { ExtractionResult result processContract(/tmp/contract.pdf); System.out.println(成功: result.success); System.out.println(合同编号: result.contractNo); System.out.println(甲方名称: result.partyA); if (result.errorMessage ! null) { System.out.println(错误: result.errorMessage); } } }这套管线是我在内部系统上持续迭代出来的版本经历了不同格式合同的实测验证。核心特点有两个异常按场景分类处理扫描件和加密文档会明确提示不会一路抛堆栈误导排查正则策略弱化了对版式的依赖“合同编号”、合同编号:、合同编号 HT20250001都能匹配对PDF提取结果里的各种不干净情况容忍度很高。7.2 配合OCR的兜底方案如果校验阶段发现是扫描件管线会自动切换到OCR分支。这个分支我在4.2节已经详细讲过渲染和识别流程这里再补充一个操作层面建议多页扫描件尽量逐页识别并拼接不要把整本PDF渲染成一张超长图片再丢给OCR。一是显存和内存扛不住二是识别精度会下降。实际项目中我还会把OCR识别的结果和iText提取的文本做一个“置信度比较”。如果两种方式提取出来的结果基本一致说明这个字段可信度极高如果差异很大就标记为人工复核。这套机制把自动化提取的错误率控制得非常低。总结一下我最真实的几点体感做PDF文本提取这几年最大的体感是PDF处理没有“银弹”靠的是一个组合拳。iText解决的是“有文本层”的PDFOCR兜底的是“扫描图片型”的PDF而正则、坐标、清洗这些技巧则是把所有结果变得“可用”的关键。每份PDF都有自己的脾气有的干净得像Windows记事本一样清爽有的脏得让人怀疑是不是故意埋了雷。但只要你把提取管线、清洗规则、异常处理这三层搭建好大部分PDF都能被驯服。最后分享一个项目经验不要等到提取结果不对了才去加日志。从第一天就记录每个PDF每页的提取结果、清洗前后字符数、命中字段的置信度这些日志在后续排查客户投诉的时候价值远高于任何测试用例。我在正式项目里一直保持着这个习惯靠它定位过字体子集缺失、页面坐标系偏移、甚至PDF版本兼容性等一堆疑难杂症。如果你也正在和PDF里的字符串较劲希望这篇整理对你有用。