夜雨聆风学习资料网

ARTICLE · 1018249

PDFBox 解析 PDF:正文好拿,表格和图片全是坑

PDFBox 解析 PDF:正文好拿,表格和图片全是坑

上一篇我们把「文档从哪来」的「四问 + 六列」列完,这篇终于写代码了。

我挑的是法律场景的硬骨头:民事判决书 PDF。

为什么挑它?因为它三种坑全占:

  1. 正文是双栏排版——法院判决书标准排版,左栏是事实与理由,右栏是判项。如果直接 PDFTextStripper 全文撸,你会读到「这法院写的东西怎么语序全是乱的」,因为它把左栏读到底再读右栏,根本不是人读顺序。

  2. 表格碎成一行行——当事人信息栏、利息计算表、赔偿项目明细表,原本是二维结构,PDFTextStripper 把它们读成「| 原告 | 张三 | 男 | 1990 年... |」,表格的结构信息全丢。

  3. 图片里的字一个都拿不到——判决书首页的法院 logo、二维码、骑缝章,以及当法院把法官手写批注扫描进 PDF 时,那些字全是像素。

        这一篇我们用 Apache PDFBox 把这三种坑当场拆给你看。

        一、环境与依赖

        Maven 依赖(pom.xml):

        <!-- PDFBox 3.0.x:JDK 17+;旧版 2.x 在 JDK 17 上跑要加 --add-opens --><dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>3.0.3</version></dependency>

        JDK 用 17+ 即可。3.0.x 对 jakarta 的依赖更干净,PDFBox 自带 Loader.loadPDF 替换掉了过时的 Loader.loadPDF+RandomAccessRead 组合写法。

        二、最简代码:按页抽正文

        // 合同纠纷 PDF 入库:按页切分,每页单独成一个 chunk 候选,方便后续切块器再细分public class JudgmentPdfLoader {public record PageChunk(int pageNo, String text) {}public List<PageChunk> loadByPage(Path pdfPath) throws IOException {        List<PageChunk> pages = new ArrayList<>();try (PDDocument doc = Loader.loadPDF(pdfPath.toFile())) {// 拿到页数;后续切块器拿到这个游标可以在 chunk metadata 里写 "page": 3int pageCount = doc.getNumberOfPages();            PDFTextStripper stripper = new PDFTextStripper();for (int i = 1; i <= pageCount; i++) {                stripper.setStartPage(i);                stripper.setEndPage(i);// 按页 setRange 后只抽这一页文本,规避双栏全文乱序的 80% 问题                String pageText = stripper.getText(doc);                pages.add(new PageChunk(i, normalize(pageText)));            }        }return pages;    }private String normalize(String raw) {// 把 PDF 里的零宽空格、连字符、连续空白压成单空格return raw.replace('\u00AD'' ').replaceAll("\\s+"" ").trim();    }}

        这就是 v0.1 的全部抽取逻辑——按页切分、不做表格识别、不抓图片、把零宽字符压掉。

        至于页面顺序错乱、表格拆碎、图片字拿不到的事,下面现场看。

        三、翻车现场 1:双栏判决书的「乱序」

        (这个坑最容易被忽视)

        下面这段是真实双栏排版(左边是事实,右边是判项):

        左栏末段:本院认为,原告主张的医疗费 12,340 元有票据支持,本院予以采信……右栏首段:依照《民法典》第一千一百七十九条……判决如下:

        我们用上面 loadByPage 抽出来打印,是这样的:

        第 3 页正文:本院认为原告主张的医疗费 12340 元有票据支持 本院予以采信 依照民法典第一千一百七十九条 判决如下

        一句话。把左栏后半截和右栏前半截糊在一起了。

        PDFTextStripper 是按 PDF 文本流的物理坐标顺序(从页面左上到右下)读,不会自动按「双栏的语义」重组。它读到的顺序是:

        [左栏第一段][左栏第二段][左栏第三段]→[右栏第一段][右栏第二段]→继续下一页

        你以为它在读「左栏从上到下 → 切右栏」,它真就是按文字块的物理坐标直线扫过去。

        我们的解法分两步:

        1. 最小可用版本
          :按页切,承认双栏问题是「后续切块器 + 阅读顺序还原器」的工作。入库时在 chunk metadata 里写 {"page": 3, "layout": "double-column"},给后续处理做提示;
        2. 正经版
          :拆 PDF 文本块(PDFBox 的 PDFTextStripper 加 setSortByPosition(true)),再按 Y 坐标聚簇、按 X 坐标分栏,输出结构化的 Block → Paragraph → Sentence。这部分代码这一篇不展开,留到第三章「识字」篇讲多模态时连同版面分析一起做。

        最坑的不是分栏,是法院的真·双栏 PDF 在每页左下角和右下角有页脚「- 本页第 3 页 共 8 页 -」和诉讼费栏,这些会被 normalize() 一起拼进正文,必须在切块器里再剥一次。

        四、翻车现场 2:表格碎成一行行

        法院判决书里,最常见的表格就是「当事人信息」和「赔偿明细」。

        PDFBox PDFTextStripper 抽出来的样本:

        当事人信息:[空格多] 原告 [空格多] 张三 [空格多] 男 [空格多] 1990 年 5 月 [空格多] ……[空格多] 被告……赔偿明细:项目 金额(元)医疗费 12340 误工费 4500 护理费 3200 ……合计 24780

        表格的二维结构丢了,全成「一行流」。

        问题来了:用户问「这案子的医疗费是多少?」,向量检索召回的是「项目 金额(元)医疗费 12340 误工费 4500 护理费 3200」这一行——模型看到的是堆在一起,吐出来的数字经常串位。

        最小可用方案(这一篇给思路,下一篇 POI 也有表格,到时候合并讲结构化):

        • 思路 A:让 PDFBox 抽表格就用 PDFTextStripper 输出后做正则
          ——「金额(元)[^X]\d+」逐项抓取,配合行首的「项目」锚词;
        • 思路 B:换库
          ——用开源 tabula-java(Apache 2.0)或商业 Aspose.PDF,能直接拿到 Table → Row → Cell 结构;
        • 思路 C:在 metadata 里单独存一份
          ——把表格抽出来单独放一个 tables/ 字段,让切块器把表格 chunk 单独建立。

        RAG 实战里,思路 A 够用 80% 的简单明细表,复杂表格(合并单元格、跨页表)必须走 B 或 C。

        这一篇不展开 tabula-java 的代码,下一篇 POI 解析 docx 表格时一并演示结构化写法。

        五、翻车现场 3:图片里的字一个都拿不到

        (这个最严重,必须留伏笔)

        法院有些判决书会扫描附加证据——手写病历复印件、签收单、质证意见。这些「纸拍照」生成的 PDF 内部其实是图片,PDFTextStripper 抽出来 ""——空字符串,没有任何文字。

        有些更狠:「双层 PDF」——表层是扫描图,底层藏一份真文字图层(OCR 结果)。PDFTextStripper 默认抽的是底层文字图层,如果图层缺失或版本错位,可能抽到的是「OCR 半成品」——错别字一堆。

        更隐蔽的是:有些法院的电子签章,是把整章戳的图片塞进 PDF 再压文字图层——PDFTextStripper 一抓一个空。

        最小可用思路:把图片单独抽出来留占位,不指望 PDFBox 读字。

        // 抽图片资源清单,给后续 OCR / 多模态接管留入口public List<String> extractImageRefs(PDDocument doc) throws IOException {    List<String> refs = new ArrayList<>();for (int i = 0; i < doc.getNumberOfPages(); i++) {        PDPage page = doc.getPage(i);        PDResources res = page.getResources();if (res == nullcontinue;// 拿到 XObject 里的图片资源;不抽像素,只在 chunk 里写占位for (COSName name : res.getXObjectNames()) {if (res.isImageXObject(name)) {                refs.add("p" + (i + 1) + "/" + name.getName());            }        }    }return refs;}

        接下来在 chunk metadata 里写 {"images": ["p1/Im0", "p3/Im1"]}OCR 留到第三章「识字·让 RAG 看懂非文本」——那一篇我们引入 PaddleOCR / 多模态大模型时一起讲。

        这一刻你只需要记住一件事:图片里的字 PDFBox 拿不到。把它当「占位符 + 索引」入库,别假装它有内容。

        六、把这套代码收成一个最小入库流程

        把上面三段拼起来,判决书 PDF 入库的最小流水线是这样的:

        1. loadByPage(path)
           拿到所有 PageChunk
        2. 每条 PageChunk 配上一份 metadata:{"docId": "民初123号", "page": 3, "layout": "double-column", "images": [...]}
        3. 写入临时 Chunk 列表,先别急着 Embedding
        4. 把这个临时列表人工抽 5 条打印出来,看看正文是不是连贯的——前面 normalize() 那段把零宽字符干掉就是这一步的命门。

        第 3 步之后接 Embedding + 向量库(EP011 我们上 Tika,EP012 我们上 LangChain4j Embedding)。这一篇我们只把 PDF 这一根管道打通。

        自检清单(v0.1 PDF 入库)

        • 按页切分正常,每页文本可在 1-2 个自然段
        • 文字零宽字符、连字符已归一化
        • 每页 chunk metadata 含有 pagelayout 字段
        • 表格 chunk 至少做了「逐项」或「单独字段」处理,不再糊成一行流
        • 图片有占位记录(路径/索引),不假装有内容
        • 抽样 5 条 chunk 人工读过、判断「能检索」

        下一篇(立骨 · 03),我们换 Java 生态解析 docx 的同款硬骨头:用 Apache POI 的 XWPFDocument 解析合同。你会看到 Word 的「修订模式」「批注」「页眉页脚」是怎么把一份作废条款继续留在语料里、然后被律师当现行合同引出去的——以及我们怎么把这些「陷阱字段」主动剥离。

        我们下一篇见。

        声明:本系列所有内容均为个人原创(含基于公开文档、资料、书籍与个人实践的重述和代码实现)。文中医疗、法律相关案例均为脱敏的合成示例,与公司核心业务完整脱离,只讲技术,不构成医疗建议或法律意见。

        相关学习资料

        返回首页浏览学习资料