【Java】采用 Tabula 技术对 PDF 文件内表格进行数据提取

某天项目组来了个需求说需要提取 PDF 文件中数据作为数据沉淀使用,这是因为第三方系统不提供数据接口所以只能够出此下策。

就据我所知,PDF 文件内数据提取目前有 3 种解决方案:

第一种,资金足够的话可以直接通过人工智能对 PDF 内容进行解析,按照你需要的规格数据进行输出即可;

第二种,采用 OCR 识别技术对内容进行提取;

第三种,通过工具实现(也是我将为您呈现的)。在开源社区中 PDFbox 人气很高,文字的识别率也很不错,但是对于表格支持不太友好,涉及到表格数据提取的我选用了 Tabula 来实现;

Tabula 是什么?

Tabula是一个开源工具,用于从PDF文档中提取表格数据。它的主要技术包括:

  1. PDF 解析:Tabula 使用 Java 的 PDFBox 库来解析 PDF 文档的内容和布局。它可以定位到每个页的文本块和图像的坐标;
  2. 表格识别:Tabula 通过分析页面上的线条和文本块的布局来识别表格的结构。它会查找垂直和水平的线条作为列和行的分隔符;
  3. 单元格提取:在确定了表格的结构后,Tabula 会分析每个单元格对应的文本块,并提取出单元格中的文本内容;
  4. 数据整理:Tabula 会尝试自动整理从表格中提取的数据,例如:纵向和横向合并单元格,处理跨页的表格等。它也会执行一定的文本清理;
  5. 导出格式:Tabula 支持将提取出来的数据导出为 CSV 和 JSON 格式。用户可以导入到 Excel 等其他工具中进行后续分析。
  6. 优化算法:Tabula 在表格分析和数据提取方面使用了一些优化的算法和启发式规则,以提高正确率。同时它也提供了交互式的编辑接口供用户校正结果。

怎么用 Tabula?

首先肯定是引入 pom 文件依赖,如下图:

xml 复制代码
<dependency>
  <groupId>technology.tabula</groupId>
  <artifactId>tabula</artifactId>
  <version>1.0.5</version>
</dependency>

接着就可以创建 PDF 工具类了(PdfUtil)

java 复制代码
public class PdfUtil {

      ...
    
      private static final SpreadsheetExtractionAlgorithm SPREADSHEEET_EXTRACTION_ALGORITHM = new SpreadsheetExtractionAlgorithm();
      private static final ThreadLocal<List<String>> THREAD_LOCAL = new ThreadLocal<>();

      ...

      /**
       * @description: 解析pdf表格(私有方法)
       *               使用 tabula-java 的 sdk 基本上都是这样来解析 pdf 中的表格的,所以可以将程序提取出来,直到 cell
       *               单元格为止
       * @param {*} String pdf 路径
       * @param {*} int 自定义起始行
       * @param {*} PdfCellCallback 特殊回调处理
       * @return {*}
       */
      private static JSONArray parsePdfTable(String pdfPath, int customStart, PdfCellCustomProcess callback) {
            JSONArray reJsonArr = new JSONArray(); // 存储解析后的JSON数组

            try (PDDocument document = PDDocument.load(new File(pdfPath))) {
                  PageIterator pi = new ObjectExtractor(document).extract(); // 获取页面迭代器

                  // 遍历所有页面
                  while (pi.hasNext()) {
                        Page page = pi.next(); // 获取当前页
                        List<Table> tableList = SPREADSHEEET_EXTRACTION_ALGORITHM.extract(page); // 解析页面上的所有表格

                        // 遍历所有表格
                        for (Table table : tableList) {
                              List<List<RectangularTextContainer>> rowList = table.getRows(); // 获取表格中的每一行

                              // 遍历所有行并获取每个单元格信息
                              for (int rowIndex = customStart; rowIndex < rowList.size(); rowIndex++) {
                                    List<RectangularTextContainer> cellList = rowList.get(rowIndex); // 获取行中的每个单元格
                                    callback.handler(cellList, rowIndex, reJsonArr);
                              }
                        }
                  }
            } catch (IOException e) {
                  LOGGER.error(MARKER,
                              "function[PdfUtil.parsePdfTable] Exception [{} - {}] stackTrace[{}]",
                              e.getCause(), e.getMessage(), e.getStackTrace());
            } finally {
                  THREAD_LOCAL.remove();
            }
            return reJsonArr; // 返回解析后的JSON数组
      }

      ...

}

这里我们先按照官网样例代码来实现 pdf 表格解析先。大致的思路就是:

  1. 创建一个空的 JSONArray 对象 reJsonArr ,用于存储解析后的表格数据;
  2. 使用 PDDocument.load 方法加载指定路径的 PDF 文件,并使用 try-with-resources 语句创建一个 PDDocument 对象 document ;
  3. 使用 ObjectExtractor 从 document 中提取页面迭代器 pi ;
  4. 使用 while 循环遍历每个页面,使用 pi.hasNext 方法判断是否还有下一个页面,如果有则进入循环;
  5. 使用 pi.next 方法获取当前页面对象 page ;
  6. 使用 SPREADSHEEET_EXTRACTION_ALGORITHM 解析 page 中的所有表格,并将结果存储在 tableList 中;
  7. 使用 for 循环遍历 tableList 中的每个表格,对于每个表格执行以下操作: a. 使用 table.getRows 方法获取表格中的每一行,并将结果存储在 rowList 中; b. 使用 for 循环遍历 rowList 中的每一行,从 customStart 位置开始,对于每一行执行以下操作: i. 使用 rowList.get 方法获取行中的每个单元格,并将结果存储在 cellList 中; ii. 将 cellList 、 rowIndex 和 reJsonArr 作为参数传递给回调函数 callback 的 handler 方法进行处理;
  8. 使用 try-catch 语句捕获可能发生的 IOException 异常,并记录错误信息;
  9. 使用 finally 语句移除 THREAD_LOCAL 中的数据;
  10. 返回解析后的 JSONArray 对象 reJsonArr ;

这里要加上一个 callback.handler 回调函数主要的目的是为了将"单元格操作"跟 pdf 解析两部分代码进行解耦,那么这个回调接口的接口定义如下:

java 复制代码
@FunctionalInterface
public interface PdfCellCustomProcess {

      /**
       * @description: 自定义单元格回调处理
       * @return {*}
       */
      void handler(List<RectangularTextContainer> cellList, int rowIndex, JSONArray reJsonArr);
}

其中 cellList 传入的是这一行的所有单元格的集合,rowIndex 传入的是当前行码,reJsonArr 是返回值。具体的实现代码如下:

java 复制代码
public class PdfUtil {

      ...
    
      /**
       * @description: 解析 pdf 中简单的表格并返回 json 数组
       * @param {*} String PDF文件路径
       * @param {*} int 自定义起始行
       * @return {*}
       */
      public static JSONArray parsePdfSimpleTable(String pdfPath, int customStart) {
            return parsePdfTable(pdfPath, customStart, (cellList, rowIndex, reArr) -> {
                  JSONObject jsonObj = new JSONObject();
                  // 遍历单元格获取每个单元格内字段内容
                  List<String> headList = ObjectUtil.isNullObj(THREAD_LOCAL.get()) ? new ArrayList<>()
                              : THREAD_LOCAL.get();

                  for (int colIndex = 0; colIndex < cellList.size(); colIndex++) {
                        String text = cellList.get(colIndex).getText().replace("\r", " ");
                        if (rowIndex == customStart) {
                              headList.add(text);
                        } else {
                              jsonObj.put(headList.get(colIndex), text);
                        }
                  }

                  if (rowIndex == customStart) {
                        THREAD_LOCAL.set(headList);
                  }

                  if (!jsonObj.isEmpty()) {
                        reArr.add(jsonObj);
                  }
            });
      }

     ...

}

代码的主要部分是一个 Lambda 表达式,它作为参数传递给 parsePdfTable 方法。Lambda 表达式做了PdfCellCustomProcess 接口的实现。Lambda 表达式的代码块首先创建一个 JSONObject 对象,然后遍历单元格列表,获取每个单元格的文本内容。

如果当前行索引等于自定义起始行索引,将文本内容添加到 headList 列表中;否则,将文本内容作为键值对添加到jsonObj 对象中。最后,如果 jsonObj 对象不为空,则将其添加到 reArr 数组中。 代码还包含了一些其他操作。如果当前行索引等于自定义起始行索引,将 headList 列表设置为 THREAD_LOCAL 线程局部变量。最后,返回 reArr数组作为方法的结果。

最后只需要补上 main 方法调用即可获取到解析后的 JsonArray 集合。但是直接输出 JsonArray 数据并不直观,于是我又写了一个解析 JsonArray 数据的方法,并将里面的数据转换为 Markdown 格式,如下图:

java 复制代码
private static String outputMdFormatForVerify(JSONArray jsonArr) {
        StringBuilder mdStrBld = new StringBuilder();
        StringBuilder headerStrBld = new StringBuilder("|");
        StringBuilder segmentStrBld = new StringBuilder("|");
        for (int row = 0; row < jsonArr.size(); row++) {
              StringBuilder bodyStrBld = new StringBuilder("|");
              JSONObject rowObj = (JSONObject) jsonArr.get(row);
              if (row == 0) {
                    rowObj.forEach((k, v) -> {
                          headerStrBld.append(" ").append(k).append(" |");
                          segmentStrBld.append(" ").append("---").append(" |");
                    });
                    headerStrBld.append("\n");
                    segmentStrBld.append("\n");
                    mdStrBld.append(headerStrBld).append(segmentStrBld);
              }
              rowObj.forEach((k, v) -> bodyStrBld.append("").append(v).append("|"));
              bodyStrBld.append("\n");
              mdStrBld.append(bodyStrBld);
        }
        return mdStrBld.toString();
}

这个应该比较好理解吧,这里就不再详述了。

以上的代码对于一般的 PDF 表格解析是基本没有问题的,但是对于带有合并单元格的解析就不能满足了。合并单元格需要考虑横向合并、纵向合并和混合合并三种合并模式,不是说 tabula-java 的 sdk 不能做只是比较麻烦,在 tabula-java 方案中我们可以获取到单元格的高和宽,那么先做一次全遍历获取二维数组对于单元格定位后,根据高和宽进行虚拟表格的建设,最后根据二维数组对数据进行回填即可。这也是用回调将单元格操作分离的原因之一,为了后面做合并单元格解析做准备的。

但其实上面说这么多,合并单元格解析的代码我还没写呢(以上都是我吹的),等完成后再给大家分享。

相关推荐
appearappear6 分钟前
Mac 上重新安装了Cursor 2.2.30,重新配置 springboot 过程记录
java·spring boot·后端
CryptoRzz15 分钟前
日本股票 API 对接实战指南(实时行情与 IPO 专题)
java·开发语言·python·区块链·maven
程序员水自流17 分钟前
MySQL数据库自带系统数据库功能介绍
java·数据库·mysql·oracle
谷哥的小弟21 分钟前
Spring Framework源码解析——RequestContext
java·后端·spring·框架·源码
天远Date Lab27 分钟前
Java微服务实战:聚合型“全能小微企业报告”接口的调用与数据清洗
java·大数据·python·微服务
lizz3132 分钟前
C++操作符重载深度解析
java·c++·算法
武子康32 分钟前
Java-205 RabbitMQ 工作模式实战:Work Queue 负载均衡 + fanout 发布订阅(手动ACK/QoS/临时队列)
java·性能优化·消息队列·系统架构·rabbitmq·java-rabbitmq·mq
IT_陈寒32 分钟前
Vite 5大优化技巧:让你的构建速度飙升50%,开发者都在偷偷用!
前端·人工智能·后端
CodeCraft Studio33 分钟前
Vaadin 25 正式发布:回归标准Java Web,让企业级开发更简单、更高效
java·开发语言·前端·vaadin·java web 框架·纯java前端框架·企业级java ui框架
Haoea!44 分钟前
JDK21新特性-序列集合
java