Java基于Tabula實(shí)現(xiàn)PDF合并單元格內(nèi)容的提取
坑還是要填的,但是填得是否平整就有待商榷了(狗頭保命...)。
本人技術(shù)有限,只能幫各位實(shí)現(xiàn)的這個地步了。各路大神如果還有更好的實(shí)現(xiàn)也可以發(fā)出來跟小弟共勉一下哈。
首先需要說一下的是以下提供的代碼僅作研究參考使用,各位在使用之前務(wù)必自檢,因?yàn)椴⒉皇撬?pdf 的表格格式都適合。
本次實(shí)現(xiàn)的難點(diǎn)在于 PDF 是一種視覺格式,而不是語義格式。
它只記錄了“在 (x, y) 坐標(biāo)繪制文本 'ABC'”和“從 (x1, y1) 到 (x2, y2) 繪制一條線”。它根本不“知道”什么是“表格”、“行”或“合并單元格”。而 Tabula 的 SpreadsheetExtractionAlgorithm 算法是處理這種問題的最佳起點(diǎn),但它提取的結(jié)果會是“不規(guī)則”的,即每行的單元格數(shù)量可能不同。因此本次將采用后處理的方式進(jìn)行解析,Tabula 更多的只是作內(nèi)容提取,表格組織還是在后期處理進(jìn)行的。
就像上次的文章中說到
【Java】采用 Tabula 技術(shù)對 PDF 文件內(nèi)表格進(jìn)行數(shù)據(jù)提取
本次解決問題的核心思路就是通過計算每一個單元格完整的邊界框,得到它的 top,left, bottom,right。通過收集所有單元格的 top 坐標(biāo)和 bottom 坐標(biāo),推斷出表格中所有“真實(shí)”的行邊界。同理,通過收集所有單元格的 left 坐標(biāo)和 right 坐標(biāo),可以推斷出所有“真實(shí)”的列邊界。最后基于這些邊界構(gòu)建一個完整的網(wǎng)格,然后將 Tabula 提取的文本塊“放”回這個網(wǎng)格中。
為了方便測試我使用了 Deepseek 官網(wǎng)“模型細(xì)節(jié)”章節(jié)里面的那個表格。

這個表格是比較經(jīng)典的,既有列合并單元格,也有行合并單元格。而且表格中并沒有那么多復(fù)雜的內(nèi)容。
下面是我的執(zhí)行代碼
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 {
// 浮點(diǎn)數(shù)比較的容差
private static final float COORDINATE_TOLERANCE = 2.0f;
/**
* 為了樣例方便,在類內(nèi)部直接封裝一個單元格,包含其文本和邊界
*/
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);
}
}
/**
* 由于表格提取的時候會出現(xiàn)偏差,因此定義表格指紋,用于去重
*/
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);
}
/**
* 生成表格的內(nèi)容 Hash,用于快速比較兩個表格是否相同
*
* Hash 生成規(guī)則:將每個單元格的文本內(nèi)容連接起來,使用 "|" 分隔 如果單元格的數(shù)量超過 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;
}
// 將要比較的對象強(qiáng)制轉(zhuǎn)換為 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;
// 兩個表格的行數(shù)和列數(shù)必須相同
boolean rowMatch = this.rowCount == other.rowCount;
boolean colMatch = this.colCount == other.colCount;
// 兩個表格的內(nèi)容 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();
}
/**
* 將坐標(biāo)四舍五入到指定精度,減少浮點(diǎn)誤差
*
* @param coord 需要四舍五入的坐標(biāo)
* @return 四舍五入后的坐標(biāo)
*/
private static float roundCoordinate(float coord) {
// 將坐標(biāo)乘以 10,然后將結(jié)果四舍五入,然后除以 10.0f,保留一個小數(shù)點(diǎn)
return Math.round(coord * 10) / 10.0f;
}
}
/**
* 解析 PDF 文件中的所有表格
*
* 1. 使用 ObjectExtractor 將 PDF 文件中的所有表格進(jìn)行提取 2. 使用 SpreadsheetExtractionAlgorithm
* 基于線條檢測表格,避免重復(fù)表格 3. 規(guī)范化表格,處理合并單元格
*
* @param pdfFile 要解析的 PDF 文件
* @return 規(guī)范化的表格數(shù)據(jù),每個 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("跳過重復(fù)表格: top=" + fingerprint.top + ", left=" + fingerprint.left);
continue;
}
seenTables.add(fingerprint);
List<List<String>> normalized = normalizeTable(table);
if (!normalized.isEmpty()) {
allNormalizedTables.add(normalized);
}
}
}
}
return allNormalizedTables;
}
/**
* 規(guī)范化表格,處理合并單元格
*
* @param table Tabula 提取的原始表格
* @return 規(guī)范化的 List<List<String>>
*/
private List<List<String>> normalizeTable(Table table) {
// 1. 提取所有單元格及其坐標(biāo)
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. 收集所有唯一的行起始位置和列起始位置,并添加結(jié)束位置
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. 轉(zhuǎn)換為列表并去除首尾(表格外邊界)
List<Float> rowCoords = new ArrayList<>(rowBoundaries);
List<Float> colCoords = new ArrayList<>(colBoundaries);
// 移除最小和最大值(表格外邊界),只保留內(nèi)部網(wǎng)格線
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. 驗(yàn)證網(wǎng)格有效性
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. 將單元格內(nèi)容填充到網(wǎng)格中
for (Cell cell : allCells) {
// 找到單元格在網(wǎng)格中的起始索引
int startRow = findCellStartIndex(rowCoords, cell.getTop());
int startCol = findCellStartIndex(colCoords, cell.getLeft());
// 容錯處理
if (startRow == -1 || startCol == -1) {
continue;
}
// 確保索引有效
if (startRow >= numRows || startCol >= numCols) {
continue;
}
// 計算單元格跨越的行數(shù)和列數(shù)
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 {
// 如果已有內(nèi)容,追加(處理重疊情況)
if (!grid[startRow][startCol].isEmpty() && !cell.text.isEmpty()) {
grid[startRow][startCol] += " " + cell.text;
} else if (!cell.text.isEmpty()) {
grid[startRow][startCol] = cell.text;
}
}
// 標(biāo)記被合并覆蓋的其他單元格
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] = ""; // 標(biāo)記為空字符串(合并單元格的一部分)
}
}
}
}
// 6. 填充空單元格:優(yōu)先從左側(cè)填充,左側(cè)為空則從上方填充
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;
// 優(yōu)先從左側(cè)獲取內(nèi)容
if (c > 0 && grid[r][c - 1] != null && !grid[r][c - 1].isEmpty()) {
fillContent = grid[r][c - 1];
}
// 左側(cè)為空或不存在,從上方獲取內(nèi)容
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. 將二維數(shù)組轉(zhuǎn)換為 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;
}
/**
* 將坐標(biāo)四舍五入到指定精度,減少浮點(diǎn)誤差
*/
private float roundCoordinate(float coord) {
return Math.round(coord * 10) / 10.0f;
}
/**
* 查找單元格起始位置在網(wǎng)格中的索引
*/
private int findCellStartIndex(List<Float> coords, float value) {
float roundedValue = roundCoordinate(value);
for (int i = 0; i < coords.size(); i++) {
// 單元格的起始位置應(yīng)該在某個網(wǎng)格線上或之前
if (roundedValue <= coords.get(i) + COORDINATE_TOLERANCE) {
return i;
}
}
return coords.size() - 1;
}
/**
* 查找單元格結(jié)束位置在網(wǎng)格中的索引
*/
private int findCellEndIndex(List<Float> coords, float value) {
float roundedValue = roundCoordinate(value);
for (int i = coords.size() - 1; i >= 0; i--) {
// 單元格的結(jié)束位置應(yīng)該在某個網(wǎng)格線上或之后
if (roundedValue >= coords.get(i) - COORDINATE_TOLERANCE) {
return i;
}
}
return 0;
}
/**
* 將 Tabula 的 Table 對象轉(zhuǎn)換為 List<List<String>>>
*
* @param table Tabula 的 Table 對象
* @return List<List<String>>>
*/
public List<List<String>> tableToListOfListOfStrings(Table table) {
// 創(chuàng)建一個列表來存儲表格內(nèi)容
List<List<String>> list = new ArrayList<>();
// 遍代表格中的每一行
for (List<RectangularTextContainer> row : table.getRows()) {
// 創(chuàng)建一個列表來存儲當(dāng)前行的內(nèi)容
List<String> rowList = new ArrayList<>();
// 遍代當(dāng)前行中的每一個單元格
for (RectangularTextContainer tc : row) {
// 將當(dāng)前單元格的內(nèi)容添加到行列表中
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) {
// 請?zhí)鎿Q為你的 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("行數(shù): " + table.size() + ", 列數(shù): " + (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();
}
}
}
關(guān)于代碼的解釋應(yīng)該都清楚的了,由于只是用作試驗(yàn)我就沒有很精細(xì)地封裝了,大家湊合著用吧。如果面對更加復(fù)雜的表格的話我建議還是不要用這種填充的方式了,直接上大廠的 OCR 接口吧。
哦,還有東西忘了說了,關(guān)于 Maven 依賴的引入如下:
<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>
好了,該填的可能填好了。
以上就是Java基于Tabula實(shí)現(xiàn)PDF合并單元格內(nèi)容的提取的詳細(xì)內(nèi)容,更多關(guān)于Java Tabula提取PDF的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
關(guān)于SpringMVC的異常處理機(jī)制詳細(xì)解讀
這篇文章主要介紹了關(guān)于SpringMVC的異常處理機(jī)制詳細(xì)解讀,SpringMVC是目前主流的Web?MVC框架之一,本文將分析SpringMVC的異常處理內(nèi)容,需要的朋友可以參考下2023-05-05
關(guān)于Selenium的UI自動化測試屏幕截圖功能實(shí)例代碼
今天小編就為大家分享一篇關(guān)于Selenium的UI自動化測試屏幕截圖功能實(shí)例代碼,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-05-05
SpringBoot整合Druid實(shí)現(xiàn)SQL監(jiān)控和數(shù)據(jù)庫密碼加密
Druid連接池是阿里巴巴開源的數(shù)據(jù)庫連接池項(xiàng)目,Druid連接池為監(jiān)控而生,內(nèi)置強(qiáng)大的監(jiān)控功能,監(jiān)控特性不影響性能,本文給大家介紹了SpringBoot整合Druid實(shí)現(xiàn)SQL監(jiān)控和數(shù)據(jù)庫密碼加密,文中有相關(guān)的代碼示例供大家參考,需要的朋友可以參考下2024-06-06
詳解Spring Data JPA動態(tài)條件查詢的寫法
本篇文章主要介紹了Spring Data JPA動態(tài)條件查詢的寫法 ,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-06-06
Java中使用Lambda表達(dá)式和函數(shù)編程示例
這篇文章介紹了Java中使用Lambda表達(dá)式和函數(shù)編程示例,該文章會演示多個示列,分別是變量聲明上下文中的lambda、return語句上下文中的lambda、賦值上下文中的lambda、lambda在數(shù)組初始值設(shè)定項(xiàng)上下文中的用法等等,需要的朋友可以參考一下2021-10-10
Java利用Spire.PDF實(shí)現(xiàn)將PDF文檔轉(zhuǎn)換為Word格式
在日常工作和學(xué)習(xí)中,我們經(jīng)常會遇到PDF文檔,本文將詳細(xì)介紹如何才能高效、精準(zhǔn)地使用Java實(shí)現(xiàn)PDF到Word的自動化轉(zhuǎn)換,感興趣的小伙伴可以了解下2025-09-09
在 IntelliJ IDEA 中安裝和配置 Java 17的實(shí)戰(zhàn)記錄
文章介紹了如何在IntelliJ IDEA中安裝和配置Java 17,包括下載JDK、配置項(xiàng)目SDK、設(shè)置語言級別以及驗(yàn)證配置,本文給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2025-10-10
java并發(fā)編程專題(十)----(JUC原子類)基本類型詳解
這篇文章主要介紹了java JUC原子類基本類型詳解的相關(guān)資料,文中示例代碼非常詳細(xì),幫助大家更好的理解和學(xué)習(xí),感興趣的朋友可以了解下2020-07-07

