【Java】基于 Tabula 的 PDF 合并单元格内容提取

坑还是要填的,但是填得是否平整就有待商榷了(狗头保命...)。

本人技术有限,只能帮各位实现的这个地步了。各路大神如果还有更好的实现也可以发出来跟小弟共勉一下哈。

首先需要说一下的是以下提供的代码仅作研究参考使用,各位在使用之前务必自检,因为并不是所有 pdf 的表格格式都适合。


本次实现的难点在于 PDF 是一种视觉格式,而不是语义格式。


它只记录了"在 (x, y) 坐标绘制文本 'ABC'"和"从 (x1, y1) 到 (x2, y2) 绘制一条线"。它根本不"知道"什么是"表格"、"行"或"合并单元格"。而 Tabula 的 SpreadsheetExtractionAlgorithm 算法是处理这种问题的最佳起点,但它提取的结果会是"不规则"的,即每行的单元格数量可能不同。因此本次将采用后处理的方式进行解析,Tabula 更多的只是作内容提取,表格组织还是在后期处理进行的。

就像上次的文章中说到

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

本次解决问题的核心思路就是通过计算每一个单元格完整的边界框,得到它的 top,left, bottom,right。通过收集所有单元格的 top 坐标和 bottom 坐标,推断出表格中所有"真实"的行边界。同理,通过收集所有单元格的 left 坐标和 right 坐标,可以推断出所有"真实"的列边界。最后基于这些边界构建一个完整的网格,然后将 Tabula 提取的文本块"放"回这个网格中。

为了方便测试我使用了 Deepseek 官网"模型细节"章节里面的那个表格。

这个表格是比较经典的,既有列合并单元格,也有行合并单元格。而且表格中并没有那么多复杂的内容。

下面是我的执行代码

java 复制代码
package cn.paohe;

import java.awt.Point;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.NavigableSet;
import java.util.Set;
import java.util.TreeSet;

import org.apache.pdfbox.pdmodel.PDDocument;

import technology.tabula.ObjectExtractor;
import technology.tabula.Page;
import technology.tabula.PageIterator;
import technology.tabula.Rectangle;
import technology.tabula.RectangularTextContainer;
import technology.tabula.Table;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;

public class MergedCellPdfExtractor {

    // 浮点数比较的容差
    private static final float COORDINATE_TOLERANCE = 2.0f;

    /**
	 * 为了样例方便,在类内部直接封装一个单元格,包含其文本和边界
	 */
    static class Cell extends Rectangle {
        public String text;
        public float top;
        public float left;
        public double width;
        public double height;

        public Cell(float top, float left, double width, double height, String text) {
            this.top = top;
            this.left = left;
            this.width = width;
            this.height = height;
            this.text = text == null ? "" : text.trim();
        }

        @Override
        public float getTop() {
            return top;
        }

        @Override
        public float getLeft() {
            return left;
        }

        @Override
        public double getWidth() {
            return width;
        }

        @Override
        public double getHeight() {
            return height;
        }

        @Override
        public float getBottom() {
            return (float) (top + height);
        }

        @Override
        public float getRight() {
            return (float) (left + width);
        }

        @Override
        public double getX() {
            return left;
        }

        @Override
        public double getY() {
            return top;
        }

        @Override
        public Point[] getPoints() {
            return new Point[0];
        }

        @Override
        public String toString() {
            return String.format("Cell[t=%.2f, l=%.2f, w=%.2f, h=%.2f, text='%s']", top, left, width, height, text);
        }
    }

    /**
	 * 由于表格提取的时候会出现偏差,因此定义表格指纹,用于去重
	 */
    static class TableFingerprint {
        private final float top;
        private final float left;
        private final int rowCount;
        private final int colCount;
        private final String contentHash;

        public TableFingerprint(Table table) {
            this.top = roundCoordinate(table.getTop());
            this.left = roundCoordinate(table.getLeft());
            this.rowCount = table.getRowCount();
            this.colCount = table.getColCount();
            this.contentHash = generateContentHash(table);
        }

        /**
		 * 生成表格的内容 Hash,用于快速比较两个表格是否相同
		 * 
		 * Hash 生成规则:将每个单元格的文本内容连接起来,使用 "|" 分隔 如果单元格的数量超过 10 个,就停止生成 Hash
		 * 
		 * @param table 需要生成 Hash 的表格
		 * @return 生成的 Hash
		 */
        private String generateContentHash(Table table) {
            StringBuilder sb = new StringBuilder();
            int cellCount = 0;
            for (List<RectangularTextContainer> row : table.getRows()) {
                for (RectangularTextContainer cell : row) {
                    sb.append(cell.getText()).append("|");
                    cellCount++;
                    if (cellCount > 10) {
                        break;
                    }
                }
                if (cellCount > 10) {
					break;
				}
			}
			return sb.toString();
		}

		@Override
		public boolean equals(Object obj) {
			if (!(obj instanceof TableFingerprint)) {
				return false;
			}

			// 将要比较的对象强制转换为 TableFingerprint
			TableFingerprint other = (TableFingerprint) obj;

			// 两个表格的 top 和 left 坐差不能超过 COORDINATE_TOLERANCE
			boolean topMatch = Math.abs(this.top - other.top) < COORDINATE_TOLERANCE;
			boolean leftMatch = Math.abs(this.left - other.left) < COORDINATE_TOLERANCE;

			// 两个表格的行数和列数必须相同
			boolean rowMatch = this.rowCount == other.rowCount;
			boolean colMatch = this.colCount == other.colCount;

			// 两个表格的内容 Hash must be equal
			boolean contentMatch = this.contentHash.equals(other.contentHash);

			// 如果以上条件都满足,则返回 true
			return topMatch && leftMatch && rowMatch && colMatch && contentMatch;
		}

		@Override
		public int hashCode() {
			return contentHash.hashCode();
		}

		/**
		 * 将坐标四舍五入到指定精度,减少浮点误差
		 * 
		 * @param coord 需要四舍五入的坐标
		 * @return 四舍五入后的坐标
		 */
		private static float roundCoordinate(float coord) {
			// 将坐标乘以 10,然后将结果四舍五入,然后除以 10.0f,保留一个小数点
			return Math.round(coord * 10) / 10.0f;
		}
	}

	/**
	 * 解析 PDF 文件中的所有表格
	 * 
	 * 1. 使用 ObjectExtractor 将 PDF 文件中的所有表格进行提取 2. 使用 SpreadsheetExtractionAlgorithm
	 * 基于线条检测表格,避免重复表格 3. 规范化表格,处理合并单元格
	 * 
	 * @param pdfFile 要解析的 PDF 文件
	 * @return 规范化的表格数据,每个 List<List<String>> 代表一个表格
	 * @throws IOException 文件读取异常
	 */
	public List<List<List<String>>> parseTables(File pdfFile) throws IOException {
		List<List<List<String>>> allNormalizedTables = new ArrayList<>();
		Set<TableFingerprint> seenTables = new HashSet<>();

		InputStream bufferedStream = new BufferedInputStream(new FileInputStream(pdfFile));
		try (PDDocument pdDocument = PDDocument.load(bufferedStream)) {
			ObjectExtractor oe = new ObjectExtractor(pdDocument);
			PageIterator pi = oe.extract();

			while (pi.hasNext()) {
				Page page = pi.next();

				// 使用 SpreadsheetExtractionAlgorithm 基于线条检测表格
				SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
				List<Table> tables = sea.extract(page);

				for (Table table : tables) {
					// 去重检查
					TableFingerprint fingerprint = new TableFingerprint(table);
					if (seenTables.contains(fingerprint)) {
						System.out.println("跳过重复表格: top=" + fingerprint.top + ", left=" + fingerprint.left);
						continue;
					}
					seenTables.add(fingerprint);

					List<List<String>> normalized = normalizeTable(table);
					if (!normalized.isEmpty()) {
						allNormalizedTables.add(normalized);
					}
				}
			}
		}
		return allNormalizedTables;
	}

	/**
	 * 规范化表格,处理合并单元格
	 * 
	 * @param table Tabula 提取的原始表格
	 * @return 规范化的 List<List<String>>
	 */
	private List<List<String>> normalizeTable(Table table) {
		// 1. 提取所有单元格及其坐标
		List<Cell> allCells = new ArrayList<>();
		for (List<RectangularTextContainer> row : table.getRows()) {
			for (RectangularTextContainer tc : row) {
				allCells.add(new Cell(tc.getTop(), tc.getLeft(), tc.getWidth(), tc.getHeight(), tc.getText()));
			}
		}

		if (allCells.isEmpty()) {
			return new ArrayList<>();
		}

		// 2. 收集所有唯一的行起始位置和列起始位置,并添加结束位置
		NavigableSet<Float> rowBoundaries = new TreeSet<>();
		NavigableSet<Float> colBoundaries = new TreeSet<>();

		for (Cell cell : allCells) {
			rowBoundaries.add(roundCoordinate(cell.getTop()));
			rowBoundaries.add(roundCoordinate(cell.getBottom()));
			colBoundaries.add(roundCoordinate(cell.getLeft()));
			colBoundaries.add(roundCoordinate(cell.getRight()));
		}

		// 3. 转换为列表并去除首尾(表格外边界)
		List<Float> rowCoords = new ArrayList<>(rowBoundaries);
		List<Float> colCoords = new ArrayList<>(colBoundaries);

		// 移除最小和最大值(表格外边界),只保留内部网格线
		if (rowCoords.size() > 2) {
			rowCoords.remove(rowCoords.size() - 1); // 移除最大值(底边)
			rowCoords.remove(0); // 移除最小值(顶边)
		}
		if (colCoords.size() > 2) {
			colCoords.remove(colCoords.size() - 1); // 移除最大值(右边)
			colCoords.remove(0); // 移除最小值(左边)
		}

		// 4. 验证网格有效性
		if (rowCoords.isEmpty() || colCoords.isEmpty()) {
			return tableToListOfListOfStrings(table);
		}

		int numRows = rowCoords.size();
		int numCols = colCoords.size();
		String[][] grid = new String[numRows][numCols];

		// 初始化所有单元格为 null
		for (int r = 0; r < numRows; r++) {
			for (int c = 0; c < numCols; c++) {
				grid[r][c] = null;
			}
		}

		// 5. 将单元格内容填充到网格中
		for (Cell cell : allCells) {
			// 找到单元格在网格中的起始索引
			int startRow = findCellStartIndex(rowCoords, cell.getTop());
			int startCol = findCellStartIndex(colCoords, cell.getLeft());

			// 容错处理
			if (startRow == -1 || startCol == -1) {
				continue;
			}

			// 确保索引有效
			if (startRow >= numRows || startCol >= numCols) {
				continue;
			}

			// 计算单元格跨越的行数和列数
			int endRow = findCellEndIndex(rowCoords, cell.getBottom());
			int endCol = findCellEndIndex(colCoords, cell.getRight());

			if (endRow == -1)
				endRow = numRows - 1;
			if (endCol == -1)
				endCol = numCols - 1;

			// 将文本放置在左上角单元格
			if (grid[startRow][startCol] == null) {
				grid[startRow][startCol] = cell.text;
			} else {
				// 如果已有内容,追加(处理重叠情况)
				if (!grid[startRow][startCol].isEmpty() && !cell.text.isEmpty()) {
					grid[startRow][startCol] += " " + cell.text;
				} else if (!cell.text.isEmpty()) {
					grid[startRow][startCol] = cell.text;
				}
			}

			// 标记被合并覆盖的其他单元格
			for (int r = startRow; r <= endRow && r < numRows; r++) {
				for (int c = startCol; c <= endCol && c < numCols; c++) {
					if (r == startRow && c == startCol) {
						continue; // 跳过左上角已填充的单元格
					}
					if (grid[r][c] == null) {
						grid[r][c] = ""; // 标记为空字符串(合并单元格的一部分)
					}
				}
			}
		}

		// 6. 填充空单元格:优先从左侧填充,左侧为空则从上方填充
		for (int r = 0; r < numRows; r++) {
			for (int c = 0; c < numCols; c++) {
				if (grid[r][c] == null || grid[r][c].isEmpty()) {
					String fillContent = null;

					// 优先从左侧获取内容
					if (c > 0 && grid[r][c - 1] != null && !grid[r][c - 1].isEmpty()) {
						fillContent = grid[r][c - 1];
					}
					// 左侧为空或不存在,从上方获取内容
					else if (r > 0 && grid[r - 1][c] != null && !grid[r - 1][c].isEmpty()) {
						fillContent = grid[r - 1][c];
					}

					if (fillContent != null) {
						grid[r][c] = fillContent;
					} else if (grid[r][c] == null) {
						grid[r][c] = "";
					}
				}
			}
		}

		// 7. 将二维数组转换为 List<List<String>>
		List<List<String>> normalizedTable = new ArrayList<>();
		for (int r = 0; r < numRows; r++) {
			List<String> normalizedRow = new ArrayList<>();
			for (int c = 0; c < numCols; c++) {
				normalizedRow.add(grid[r][c] == null ? "" : grid[r][c]);
			}
			normalizedTable.add(normalizedRow);
		}

		return normalizedTable;
	}

	/**
	 * 将坐标四舍五入到指定精度,减少浮点误差
	 */
	private float roundCoordinate(float coord) {
		return Math.round(coord * 10) / 10.0f;
	}

	/**
	 * 查找单元格起始位置在网格中的索引
	 */
	private int findCellStartIndex(List<Float> coords, float value) {
		float roundedValue = roundCoordinate(value);

		for (int i = 0; i < coords.size(); i++) {
			// 单元格的起始位置应该在某个网格线上或之前
			if (roundedValue <= coords.get(i) + COORDINATE_TOLERANCE) {
				return i;
			}
		}

		return coords.size() - 1;
	}

	/**
	 * 查找单元格结束位置在网格中的索引
	 */
	private int findCellEndIndex(List<Float> coords, float value) {
		float roundedValue = roundCoordinate(value);

		for (int i = coords.size() - 1; i >= 0; i--) {
			// 单元格的结束位置应该在某个网格线上或之后
			if (roundedValue >= coords.get(i) - COORDINATE_TOLERANCE) {
				return i;
			}
		}

		return 0;
	}

	/**
	 * 将 Tabula 的 Table 对象转换为 List<List<String>>>
	 * 
	 * @param table Tabula 的 Table 对象
	 * @return List<List<String>>>
	 */
	public List<List<String>> tableToListOfListOfStrings(Table table) {
		// 创建一个列表来存储表格内容
		List<List<String>> list = new ArrayList<>();

		// 遍代表格中的每一行
		for (List<RectangularTextContainer> row : table.getRows()) {
			// 创建一个列表来存储当前行的内容
			List<String> rowList = new ArrayList<>();

			// 遍代当前行中的每一个单元格
			for (RectangularTextContainer tc : row) {
				// 将当前单元格的内容添加到行列表中
				String cellText = tc.getText() == null ? "" : tc.getText().trim();
				rowList.add(cellText);
				rowList.add(tc.getText() == null ? "" : tc.getText().trim());
			}

			// 将行列表添加到表格列表中
			list.add(rowList);
		}
		return list;
	}

	public static void main(String[] args) {
		// 请替换为你的 PDF 文件路径
		String pdfPath = "/Users/yuanzhenhui/Desktop/测试用合并单元格解析.pdf";

		File pdfFile = new File(pdfPath);
		if (!pdfFile.exists()) {
			System.err.println("错误: 测试文件未找到: " + pdfPath);
			System.err.println("请在 main 方法中替换为你本地的 PDF 文件路径。");
			return;
		}

		MergedCellPdfExtractor extractor = new MergedCellPdfExtractor();
		try {
			System.out.println("开始解析: " + pdfPath);
			List<List<List<String>>> tables = extractor.parseTables(pdfFile);

			System.out.println("解析完成,共找到 " + tables.size() + " 个表格。");
			System.out.println("========================================");

			int tableNum = 1;
			for (List<List<String>> table : tables) {
				System.out.println("\n表格 " + (tableNum++) + ":");
				System.out.println("行数: " + table.size() + ", 列数: " + (table.isEmpty() ? 0 : table.get(0).size()));
				System.out.println("----------------------------------------");

				for (List<String> row : table) {
					System.out.print("|");
					for (String cell : row) {
						String cellText = cell.replace("\n", " ").replace("\r", " ");
						if (cellText.length() > 15) {
							cellText = cellText.substring(0, 12) + "...";
						}
						System.out.print(String.format(" %-15s |", cellText));
					}
					System.out.println();
				}
				System.out.println("----------------------------------------");
			}

		} catch (IOException e) {
			System.err.println("解析 PDF 时出错: " + e.getMessage());
			e.printStackTrace();
		}
	}
}

关于代码的解释应该都清楚的了,由于只是用作试验我就没有很精细地封装了,大家凑合着用吧。如果面对更加复杂的表格的话我建议还是不要用这种填充的方式了,直接上大厂的 OCR 接口吧。

哦,还有东西忘了说了,关于 Maven 依赖的引入如下:

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

好了,该填的可能填好了。接下来的分享将继续回归到人工智能和区块链当中,欢迎您继续关注我的博客。

相关推荐
SimonKing6 小时前
TeamViewer、向日葵平替?这几款免费远程控制软件,真香!
java·后端·程序员
Json____6 小时前
最近我用springBoot开发了一个二手交易管理系统,分享一下实现方式~
java·spring boot·后端
YuanlongWang6 小时前
C#基础——GC(垃圾回收)的工作流程与优化策略
java·jvm·c#
爱吃生蚝的于勒6 小时前
【Linux】深入理解进程(一)
java·linux·运维·服务器·数据结构·c++·蓝桥杯
毅炼6 小时前
常见排序算法
java·算法·排序算法
自由会客室6 小时前
在 Ubuntu24.04 上安装 JDK 21(Java 21)
java·开发语言
喜欢读源码的小白6 小时前
SpringBoot的启动流程原理——小白的魔法引擎探秘
java·开发语言·spring boot·springboot启动原理
白露与泡影7 小时前
BAT 大厂 java高频面试题汇总:JVM+Spring+ 分布式 +tomcat+MyBatis
java·jvm·spring
Han.miracle7 小时前
数据结构——排序的学习(一)
java·数据结构·学习·算法·排序算法