ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

使用iText从PDF中提取关键字符串:版本、定位与性能实战

使用iText从PDF中提取关键字符串:版本、定位与性能实战 最近接了个需求要从几百份PDF合同里批量提取“订单号”“合同编号”“金额”这一类关键字符串。一开始我以为这事很简单毕竟PDF打开后文字都能看到、能复制结果真上手才发现用代码把PDF里的文字“抠”出来跟人眼看到的内容完全是两回事。折腾一圈后我把主力方案锁定在用iText读取PDF文件、再按规则提取对应字符串上。这篇文章就围绕这套方案展开。我会先讲清楚怎么选iText版本、怎么写最基础的提取代码再重点讲“怎么从整页文本里把目标字符串精准捞出来”包括关键词定位、坐标区域提取、中文编码、生僻字、大文件性能等实战问题。不管你是第一次接触PDF解析还是已经写过一两版提取代码但被各种乱码和顺序问题折磨过这篇都够用。1. 先对号入座你该用iText的哪个“分支”做文本提取1.1 两个时代的API差距iText这库在Java里算是PDF操作的老牌工具了但它的版本演化坑特别多网上搜到的代码经常互相冲突。最典型的就是同一个类名在两个版本里包名完全不同。早期版本2.x及更早用的是com.lowagie.text这个包名。后来项目重构iText 5.x换成了com.itextpdf.text再后来iText 7.x又大改了一次整个API结构都换了。现在你搜“itext pdf提取字符串”最常看到的是iText 5的PdfTextExtractor其次是iText 7的com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor。还有一个叫OpenPDF的库是iText 4时代的一个分支很多老项目里还在用它的包名又是com.lowagie.text.pdf.parser.PdfTextExtractor。所以你先别急着写代码第一步应该是确认你项目里实际引用了哪个库、哪个版本否则照着网上的代码抄很容易出现NoClassDefFoundError或者java.lang.NoSuchMethodError这种一看就头疼的报错。给个快速对照表库常见包名文本提取入口许可证iText 5.xcom.itextpdf.textPdfTextExtractor.getTextFromPageAGPL/商业授权iText 7.xcom.itextpdf.kernelPdfTextExtractor.getTextFromPageAGPL/商业授权OpenPDFcom.lowagie.textPdfTextExtractor.getTextFromPageLGPL/MPL注意iText的协议是AGPL如果你是内部工具自己用问题不大但要做成对外售卖的商业软件得评估一下License风险。OpenPDF相对宽松一些功能也够用但它的社区更新和高级特性不如iText。1.2 不是所有PDF都能“抽取”文本这里必须把一条底层逻辑讲清楚PDF里的文字在多数情况下并不是以“字符串”形式存储的而是以“绘制指令”形式存在。文件内容流里记录的是“在(x,y)位置用某某字体绘制一串字符编码”至于这些编码到底对应哪个Unicode字符需要查字体里的ToUnicode CMap才能转换出来。所以iText这类文本提取工具能顺利工作前提是这个PDF在生成时写入了正确的ToUnicode映射。大部分用Word、WPS、TeX、正规PDF库生成的PDF都没问题但有些老旧的打印驱动、专业排版软件生成的PDF提取出来可能是一串乱码、空字符串或者直接丢字。还有更常见的情况你拿到的是扫描件PDF说白了就是一页一张图片内容流里根本没有文字对象。这时候用iText怎么提取都是空手而归应该走OCR路线。这个我后面专门讲。1.3 Maven依赖怎么加避免冲突如果你决定用iText 5依赖是这样的dependency groupIdcom.itextpdf/groupId artifactIditextpdf/artifactId version5.5.13.3/version /dependency5.5.13.3是5.x的最后一个版本之后就转投7代了。如果新项目我更建议直接用iText 7dependency groupIdcom.itextpdf/groupId artifactIditext7-core/artifactId version7.2.5/version typepom/type /dependency注意itext7-core是个聚合POM会把kernel、io、layout、forms这些模块都带进来。如果你只需要读取文本理论上kernel和io就够了但为了省事直接引itext7-core没毛病。依赖冲突是高频问题。一个项目里同时存在老版的lowagie和新的itextpdf运行时就会出现ClassNotFoundException。真遇到这种局面别硬解用mvn dependency:tree把依赖链拉出来找到是谁带进来的能排除就exclusion排除。2. 最小可运行代码把PDF第一页的全部文字捞出来2.1 iText 5.x的完整示例装好依赖后最朴素的需求就是“把整个PDF的文字提取出来”。iText 5的写法非常简洁import com.itextpdf.text.pdf.PdfReader; import com.itextpdf.text.pdf.parser.PdfTextExtractor; import java.io.FileInputStream; import java.io.InputStream; public class ExtractPdfText5 { public static void main(String[] args) throws Exception { try (InputStream in new FileInputStream(sample.pdf)) { PdfReader reader new PdfReader(in); int pageCount reader.getNumberOfPages(); for (int i 1; i pageCount; i) { String text PdfTextExtractor.getTextFromPage(reader, i); System.out.println( 第 i 页 ); System.out.println(text); } reader.close(); } } }这段代码很简单但有两个细节提醒一下第一PdfReader可以直接传文件路径也可以传InputStream。如果你用InputStream后面reader.close()会把流也关掉所以建议直接用路径构造或者用try-with-resources管理。第二getTextFromPage默认使用的是LocationTextExtractionStrategy它会尽量按文本在页面上的空间位置返回结果段落之间一般有换行相对友好。如果默认策略拿到的东西不满足需求后面可以换别的策略。2.2 iText 7.x的写法差异iText 7的API表面看差不多但类名和包名都换了import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; import java.io.File; public class ExtractPdfText7 { public static void main(String[] args) throws Exception { try (PdfDocument doc new PdfDocument(new PdfReader(sample.pdf))) { int pageCount doc.getNumberOfPages(); for (int i 1; i pageCount; i) { String text PdfTextExtractor.getTextFromPage(doc.getPage(i)); System.out.println( 第 i 页 ); System.out.println(text); } } } }两版区别很明显iText 7里是PdfDocument包PdfReaderdoc.close()时底层reader会被一并关掉。另外7代的getTextFromPage接收的是PdfPage对象不是页码。2.3 先写一个命令行小工具验证在实际业务里我通常不会一上来就写复杂的抽取规则而是先写个极简工具把所有文本落盘肉眼看一下“PDF里到底是什么样的字符串”。比如这样import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor; import java.io.FileWriter; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; public class DumpPdfText { public static void main(String[] args) throws Exception { if (args.length 2) { System.err.println(用法: java DumpPdfText input.pdf output.txt); return; } try (PdfDocument doc new PdfDocument(new PdfReader(args[0])); PrintWriter writer new PrintWriter(new FileWriter(args[1], StandardCharsets.UTF_8))) { int pageCount doc.getNumberOfPages(); for (int i 1; i pageCount; i) { writer.println( Page i ); writer.println(PdfTextExtractor.getTextFromPage(doc.getPage(i))); } } } }把这个工具跑一遍你就能看到文本里哪些地方有空格、哪些地方无故换行、哪些页面是扫描图片。后续怎么写提取规则心里就有底了。3. 从“一堆文字”到“对应字符串”定位与抽取3.1 先想清楚目标“提取对应字符串”有两层含义第一层是把整篇PDF文字提取出来第二层是从这些文字里找到你真正想要的那部分。很多需求文档写的是“提取订单号”“提取发票代码”但实际上你要做的往往有两步全文提取 目标字段抽取。我在做合同抽取时踩过最深的坑就是直接把getTextFromPage的返回结果拿去indexOf(合同编号)。看着好像没问题实际一跑发现提取出来的字符串里到处是换行、空格甚至全角冒号和半角冒号混用。indexOf直接找不到目标或者找到了却截取出一堆空白字符。所以抽取前最有效的动作是先做字符串归一化。3.2 关键词定位与容错处理我常用的归一化函数长这样public class TextNormalizer { public static String normalize(String raw) { if (raw null) { return ; } // 去掉各种空白字符包括不间断空格 String noSpace raw.replaceAll([\\s\\u00A0], ); // 全角冒号/括号转半角 noSpace noSpace.replace(, :).replace(, ().replace(, )); return noSpace; } }把所有空白全部去掉是因为PDF在排版时经常把“合同编号”里的“号”和后面的冒号之间插入一个空格或换行保留空格会让关键词匹配失败。但要注意去掉所有空格也可能导致“发票号码 123”这种有实际分隔的内容黏在一起。所以我会分两版处理先replaceAll(\\s, )压缩成单空格试着匹配失败后再所有空白全去掉重试。真正字段提取我推荐用正则。例如import java.util.regex.Matcher; import java.util.regex.Pattern; public class FieldExtractor { public static void main(String[] args) { String normalized normalize(合同编号C2023-0826甲方某某公司); Pattern pattern Pattern.compile(合同编号[:]?([A-Z0-9\\-])); Matcher matcher pattern.matcher(normalized); if (matcher.find()) { System.out.println(订单号 matcher.group(1)); } } }这里注意正则里的[:]?表示冒号可有可无但如果你允许冒号缺省可能会把“合同编号C2023”这种写法也匹配上准确性需要自己评估。还一个细节有些PDF里全半角字符混排Normalizer.normalize并不能把全角字母数字变成半角。你可以在归一化函数里把全角字母数字区间\uFF10-\uFF19替换成半角。这个看业务需要不必过度设计。3.3 按坐标区域提取全文文本定位适合关键词明确的场景但有些PDF版式里根本没有文字标签只能在固定位置取值。比如发票版式里“发票号码”在左上角我要的就是那个区域里的字符。这时候应该按坐标区域提取。先说坐标系。PDF的坐标系原点在页面左下角x轴向右y轴向上单位是pt点1英寸72pt。这跟屏幕UI常用的“左上角原点、y向下”完全反过来所以但凡用坐标第一件事就是确认页面尺寸和原点。iText 5里区域提取的经典写法import com.itextpdf.text.Rectangle; import com.itextpdf.text.pdf.PdfReader; import com.itextpdf.text.pdf.parser.FilteredTextRenderListener; import com.itextpdf.text.pdf.parser.LocationTextExtractionStrategy; import com.itextpdf.text.pdf.parser.PdfTextExtractor; import com.itextpdf.text.pdf.parser.RegionTextRenderFilter; import com.itextpdf.text.pdf.parser.RenderFilter; public class RegionExtract5 { public static void main(String[] args) throws Exception { PdfReader reader new PdfReader(sample.pdf); // 左下角(100, 500)右上角(300, 700)的矩形区域 Rectangle rect new Rectangle(100, 500, 300, 700); RenderFilter filter new RegionTextRenderFilter(rect); String text PdfTextExtractor.getTextFromPage(reader, 1, new FilteredTextRenderListener( new LocationTextExtractionStrategy(), filter)); System.out.println(text); reader.close(); } }iText 7的写法略有不同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.PdfCanvasProcessor; import com.itextpdf.kernel.pdf.canvas.parser.filter.RegionTextRenderFilter; import com.itextpdf.kernel.pdf.canvas.parser.listener.FilteredEventListener; import com.itextpdf.kernel.pdf.canvas.parser.listener.LocationTextExtractionStrategy; public class RegionExtract7 { public static void main(String[] args) throws Exception { try (PdfDocument doc new PdfDocument(new PdfReader(sample.pdf))) { PdfPage page doc.getPage(1); Rectangle rect new Rectangle(100, 500, 300, 700); FilteredEventListener listener new FilteredEventListener(); LocationTextExtractionStrategy strategy listener.attachEventListener( new LocationTextExtractionStrategy(), new RegionTextRenderFilter(rect)); new PdfCanvasProcessor(listener).processPageContent(page); System.out.println(strategy.getResultantText()); } } }坐标区域提取看着简单真正调起来很麻烦。最开始你可能不知道目标文字到底落在哪个pt区间我的做法是写一个调试工具把页面里每个TextChunk的位置和内容都打出来再拿着这些坐标去定过滤矩形。iText 7的LocationTextExtractionStrategy提供了getTextChunkLocations之类的接口可以拿到文字块边界。3.4 提取表格、发票号之类的字段如果目标字符串在表格里纯坐标提取容易把上下行的内容混进来。这时可以退一步先按行提取再把每行按空格切分再按单元格语义过滤。比如发票上常见这样的输出项目名称 单价 数量 金额 技术服务费 1000.00 2 2000.00我一般按行处理每行用连续空白切分成多个列然后靠“金额”“小计”这类关键词找到目标行再取最后一列。这个方法比坐标区域省心很多因为不用关心字体的精确位置。但也要承认PDF表格提取没有一个普适的开箱方案。碰到样式乱七八糟的PDF最保底的方法还是“把整页文字提取出来再用分行/关键词/正则组合去解析”。真到了必须按网格取值的程度就该考虑付费的商业PDF数据提取产品或者干脆上机器学习模型了。4. 中文、生僻字、字体编码是绕不开的坎4.1 为什么有的PDF复制出来是乱码用iText提取PDF文字时最气人的不是提取不到而是提取出来每个字都是“?”或者一排私用区字符。这个本质是字体映射问题。PDF内容流里的文字操作符只记录“字形IDGlyph ID”和“字符编码”不是直接的Unicode。正常生成的PDF会在字体字典里带一个ToUnicode CMap把编码映射回Unicode。iText提取时就是靠这个映射还原成字符串。但如果生成PDF的工具没有写ToUnicode或者写了但映射错误iText就无从还原。这时候你看到的就是乱码。更隐蔽的情况是同一个PDF里部分字体正常、部分字体异常可能就那一两页出问题。可以做一个快速检查用PDF阅读器打开文件选中一段文字复制粘贴到记事本里如果粘贴出来也是乱码那基本可以断定是PDF本身缺少正确映射换什么库都没用只能OCR。如果复制是正常的但iText提取乱码那可能是你的调用姿势或者版本问题先排查依赖版本冲突。4.2 提取时字体回退的问题做文本提取时iText实际上不需要系统字体也能完成工作因为映射靠的是PDF内部的ToUnicode不靠操作系统的字库。所以在无字体环境的Docker容器里提取文本一般也能跑。但如果你的流程变成了“提取之后再渲染页面图片”“根据文字坐标和字体去做高亮”那就需要系统字库参与。这时候会遇到Linux服务器上没装中文字体的问题。解决办法也很直接装fonts-noto-cjk或者fonts-wqy-zenhei这类字体包然后在代码里通过FontFactory或PdfFontFactory注册字体目录。4.3 生僻字与Flying Saucer场景的延伸热词里有个“itext flying saucer 生僻字”这个我太有感触了。Flying Saucer是Java里把XHTML/CSS渲染成PDF的库底层可以接iText。很多人用它生成PDF时发现某些生僻字比如人名里的“”“䶮”显示成方框。原因很简单渲染时用的默认字体没有覆盖这些CJK扩展区字符。解决办法是给Flying Saucer的ITextRenderer注册一个覆盖更广的字体比如思源黑体、Noto Sans CJK SCITextRenderer renderer new ITextRenderer(); renderer.getFontResolver().addFont(/path/to/NotoSansCJKsc-Regular.otf, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);有个重要提醒生成时注册的字体对后续“读取”也有影响。如果你把一个生僻字用不支持它的字体渲染进PDF显示和提取可能都会出问题。反过来如果生成PDF用的字体和ToUnicode映射没问题那后面无论用iText还是别家库提取都能正常拿到生僻字。4.4 把PDF转图片后OCR什么时候才需要当iText提取出来的文本为空、乱码或者PDF本身就是扫描件时就该换OCR方案。我的习惯是先判断如果getTextFromPage返回的字符串长度除以页面大小小于某个阈值比如一页返回不足10个字符而页面尺寸又很大那就认定它是扫描件需要OCR。流程一般是两步先用PDFBox渲染页面为图片再用Tesseract识别。import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.PDFRenderer; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; public class PdfToImage { public static void main(String[] args) throws Exception { try (PDDocument doc PDDocument.load(new File(scanned.pdf))) { PDFRenderer renderer new PDFRenderer(doc); BufferedImage image renderer.renderImageWithDPI(0, 300); ImageIO.write(image, png, new File(page0.png)); } } }OCR部分用Tesseract时要确保安装了中文语言包并用tess4j调用import net.sourceforge.tess4j.Tesseract; public class OcrDemo { public static void main(String[] args) throws Exception { Tesseract tesseract new Tesseract(); tesseract.setLanguage(chi_simeng); String result tesseract.doOCR(new File(page0.png)); System.out.println(result); } }OCR比文本提取慢一个数量级能不碰尽量不碰。但真遇到扫描版合同这一套流程能救命。5. 大文件与生产环境性能、内存、无头服务器5.1 批量处理时别把全文堆在内存里批量解析PDF时最常见的OOM写法就是把几百个文件、几千页的文本全append到一个StringBuilder里再统一处理。PDF里一页文本虽然通常不大但上万页累积起来就是几十上百MB再加上提取过程中产生的中间对象JVM内存分分钟被压垮。正确做法是逐页处理、处理完就丢。比如从PDF里提取“合同编号”你可以按页提取每次只保留当前页的文本找到结果就记录并退出循环不继续读后面的页。5.2 只需要某一页或某几页的提取很多业务并不需要整个PDF的文本比如发票就提取第一页合同就提取签章页。这时候没必要遍历全部页面直接按页号标记目标即可public static String extractPage(PdfDocument doc, int pageNumber) { if (pageNumber 1 || pageNumber doc.getNumberOfPages()) { return ; } return PdfTextExtractor.getTextFromPage(doc.getPage(pageNumber)); }注意页码从1开始。如果只知道“最后几页”可以用doc.getNumberOfPages()倒推。5.3 Linux服务器上字体与中文环境的坑我们在开发机上跑得好好的代码部署到一台精简CentOS或者Docker容器里经常遇到各种诡异问题。先说纯文本提取。它一般不依赖系统字体所以只要不是扫描件OCR基本没问题。但如果你在容器里跑的是“提取文本渲染图片”的组合流程就会遇到java.awt.FontConfiguration找不到中文字体、或渲染出来的图片中文全是方块。解法镜像里装fontconfig和中文字体包Java进程加上-Djava.awt.headlesstrue避免在无窗口环境下触发AWT图形界面初始化错误。5.4 并发、超时与异常处理批量解析是CPU密集型任务线程数建议不要超过CPU核数太多。一般来说4核机器开4到8个线程比较合适。每个文件都可能坏掉所以解析单文件最好包在独立任务里失败只记日志不影响后续ExecutorService pool Executors.newFixedThreadPool(4); for (File file : pdfFiles) { pool.submit(() - { try { processOnePdf(file); } catch (Exception e) { log.error(解析失败: {}, file.getName(), e); } }); }加密文件是一个独立分支。某些PDF打开时要求密码iText构造PdfReader会抛异常。有密码就传密码iText 5是new PdfReader(path, password.getBytes())iText 7是new PdfReader(path, new ReaderProperties().setPassword(password.getBytes()))。有一点容易被误解PDF允许复制文本的权限位如果被关闭并不妨碍iText读取和提取文本。因为这些权限只是阅读器软件主动遵守的“行为限制”不是加密本身。绝大多数工具都能绕过这个限制直接提取但从合规角度你最好确认自己有权处理这些文档。6. 实际业务里的几个疑难场景排查记录6.1 有些页面提取出来是空字符串我排查过一个案例某个PDF前3页能提取出文本第4页开始全是空字符串。用PDF阅读器打开看第4页确实有大量文字。后来定位到原因那几页的文字不是直接画在页面内容流里的而是被放进了Form XObject或者由某个外部继承资源里的字体渲染。iText默认策略对多级Form XObject的处理偶尔会有遗漏换用SimpleTextExtractionStrategy或者手动处理XObject后就能捞出来。另一个常见原因是那一页根本没有文字对象只有图片。打开PDF视觉上看到的是图片里拍的一段文字。这种情况只能OCR。6.2 文本顺序错乱双栏、页眉页脚提取顺序不等于视觉阅读顺序。iText默认按PDF内容流里文字绘制的前后顺序输出很多双栏排版的PDF左栏和右栏的文字会交错出现。更别说页眉页脚、页码会混在正文中间。我处理双栏PDF时比较粗暴有效的方法是把页面按中线分成左右两个区域各自提取再人工拼接先拿page.getPageSize()拿到宽高。定义左半区域Rectangle(0, 0, width/2, height)和右半区域Rectangle(width/2, 0, width/2, height)。用区域过滤器分别提取拼接成“先左后右”的顺序。页眉页脚这类杂音用坐标过滤掉顶部和底部区域即可。如果不知道页眉具体高度可以先全页提取看一眼文本再根据关键词过滤掉“第 x 页 / 共 x 页”之类的内容。6.3 用iText 7把文本和图片分层输出顺带解决“双层PDF”需求虽然主题是读取PDF但实际项目里经常遇到“既要保留扫描图片又要能搜索文字”的需求也就是生成带文本层的双层PDF。这块在Java里同样可以用iText实现而且C#开发者会用iText7API大体一致参考价值也很大。核心思路是页面底层放扫描图片顶层用透明文本放置OCR识别的文字并用PdfLayerOCG可选内容组把两层分开管理。import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfPage; import com.itextpdf.kernel.pdf.PdfReader; import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.kernel.pdf.layer.PdfLayer; import com.itextpdf.layout.Canvas; import com.itextpdf.layout.element.Paragraph; import com.itextpdf.layout.property.TextAlignment; import java.io.File; public class CreateLayeredPdf { public static void main(String[] args) throws Exception { try (PdfDocument pdfDoc new PdfDocument( new PdfReader(scanned.pdf), new PdfWriter(layered.pdf))) { for (int p 1; p pdfDoc.getNumberOfPages(); p) { PdfPage page pdfDoc.getPage(p); PdfLayer textLayer new PdfLayer(text-layer- p, pdfDoc); Canvas canvas new Canvas(page, page.getPageSize()); canvas.add(new Paragraph(这里是OCR识别出的文字) .setFixedPosition(50, 500, 300) .setFontSize(12)); canvas.close(); } } } }这只是示意真正做双层PDF还需要把OCR单词的位置、字体大小都还原到原坐标工作量大一些。但它很有价值既保留原貌又能被iText继续提取。6.4 加密与权限限制的边界加密PDF分两类一类是打开需要密码另一类是只限制了打印、复制等权限但打开不需要密码。后者iText提取通常没问题。前者就需要你在构造reader时传密码。我一直保留一个原则只处理来源明确、拥有合法处理权限的PDF。项目里如果用户上传的PDF密码不明就直接返回“无法解析”不要试图绕过密码保护。最后分享一点我的操作习惯这套流程走了好几轮以后我现在的固定做法是先写一个“PDF文本转储工具”把文本全部落到本地肉眼确认质量再针对具体字段写归一化正则/坐标提取遇到乱码或空文本先判断是不是扫描件再决定是换解析策略还是上OCR。另外千万别把“能打开复制”当成“一定能提取”PDF的文本提取本质上是在跟字体映射打交道越早理解这一点踩坑越少。如果你只是临时要提取几个PDF里的字符串直接抄前面的代码就够了如果要做生产级批量解析建议把异常捕获、失败文件列表、解析日志这三件套先加上后面会少很多“半夜起来看日志”的体验。
返回列表