Java 程序员第 42 阶段02:文档智能解析审核,大模型实现合同摘要与合规校验,PDF文档解析技术实现
概述
PDF(Portable Document Format)是企业文档交换的事实标准,尤其在合同领域应用最为广泛。相比Word等可编辑格式,PDF具有跨平台一致性、内容保护等优势,但同时也给文档解析带来了挑战。
本文将详细介绍如何使用Apache PDFBox实现PDF文档的智能解析,涵盖文本提取、布局分析、表格识别、图片提取等核心技术。通过本文的学习,读者将能够掌握PDF文档处理的完整技术方案。
1 Apache PDFBox 概述
1.1 PDFBox 简介
Apache PDFBox是一个纯Java编写的开源PDF处理库,主要功能包括:
- PDF文档加载与创建
- 文本提取(纯文本、带格式文本)
- 页面渲染为图像
- 数字签名验证
- 表单填充
- 文本搜索与替换
- 表格结构提取
- 元数据处理
PDFBox 3.x是目前的稳定版本,相比2.x版本有显著的性能提升和API改进:
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.0</version>
</dependency>
[xml]
1.2 核心类说明
PDFBox的核心类主要包括:
|
类名 |
功能描述 |
|
PDDocument |
PDF文档对象,代表整个PDF文件 |
|
PDPage |
单个页面对象 |
|
PDFTextStripper |
文本提取器,将PDF文本提取为字符串 |
|
PDFParser |
PDF解析器,负责解析PDF文件结构 |
|
PDResources |
页面资源,包含字体、图像、XObject等 |
|
PDImageXObject |
PDF中的图像对象 |
|
PDPageContentStream |
页面内容流,用于绘制图形和文本 |
2 PDF文档解析基础
2.1 文档加载与基本信息提取
@Service
@Slf4j
public class PdfBasicParser {
/**
* 加载PDF文档并提取基本信息
*/
public PdfInfo loadPdfInfo(String filePath) throws IOException {
try (PDDocument document = PDDocument.load(new File(filePath))) {
PdfInfo info = new PdfInfo();
// 文档元数据
PDDocumentInformation metadata = document.getDocumentInformation();
info.setTitle(metadata.getTitle());
info.setAuthor(metadata.getAuthor());
info.setSubject(metadata.getSubject());
info.setCreator(metadata.getCreator());
info.setProducer(metadata.getProducer());
info.setCreationDate(metadata.getCreationDate());
info.setModificationDate(metadata.getModificationDate());
// 页面信息
info.setPageCount(document.getNumberOfPages());
info.setEncrypted(document.isEncrypted());
// 获取文档Catalog
PDDocumentCatalog catalog = document.getDocumentCatalog();
log.info("PDF加载成功: 页数={}, 标题={}, 作者={}",
info.getPageCount(),
info.getTitle(),
info.getAuthor());
return info;
}
}
/**
* 验证PDF是否为有效文档
*/
public boolean isValidPdf(String filePath) {
try (PDDocument document = PDDocument.load(new File(filePath))) {
return document.getNumberOfPages() > 0;
} catch (IOException e) {
log.error("PDF验证失败: {}", e.getMessage());
return false;
}
}
}
// 信息类
@Data
public class PdfInfo {
private String title;
private String author;
private String subject;
private String creator;
private String producer;
private Calendar creationDate;
private Calendar modificationDate;
private int pageCount;
private boolean encrypted;
}
[java]
**运行结果:**
PDF加载成功: 页数=15, 标题=软件开发合同, 作者=法务部, 创建日期=2024-01-10
文档验证: 有效PDF文档, 加密状态=false
2.2 文本提取
PDFTextStripper是PDFBox中最重要的文本提取类:
@Service
@Slf4j
public class PdfTextExtractor {
private final TextCleaner textCleaner;
/**
* 提取完整文本
*/
public String extractFullText(String filePath) throws IOException {
try (PDDocument document = PDDocument.load(new File(filePath))) {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true); // 按位置排序,保证阅读顺序
String text = stripper.getText(document);
log.info("提取文本长度: {} 字符", text.length());
return text;
}
}
/**
* 提取指定页面范围文本
*/
public String extractTextRange(String filePath, int startPage, int endPage)
throws IOException {
try (PDDocument document = PDDocument.load(new File(filePath))) {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(startPage);
stripper.setEndPage(endPage);
String text = stripper.getText(document);
log.info("提取页面 {}-{} 文本, 长度: {} 字符",
startPage, endPage, text.length());
return text;
}
}
/**
* 逐页提取文本
*/
public List<PageText> extractTextByPage(String filePath) throws IOException {
List<PageText> pageTexts = new ArrayList<>();
try (PDDocument document = PDDocument.load(new File(filePath))) {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true);
for (int i = 1; i <= document.getNumberOfPages(); i++) {
stripper.setStartPage(i);
stripper.setEndPage(i);
PageText pageText = new PageText();
pageText.setPageNumber(i);
pageText.setRawText(stripper.getText(document));
pageText.setCleanText(textCleaner.clean(pageText.getRawText()));
pageTexts.add(pageText);
}
}
log.info("共提取 {} 页文本", pageTexts.size());
return pageTexts;
}
/**
* 带位置信息的文本提取
*/
public List<PositionedText> extractTextWithPosition(String filePath)
throws IOException {
List<PositionedText> result = new ArrayList<>();
try (PDDocument document = PDDocument.load(new File(filePath))) {
PDFTextStripperByArea stripper = new PDFTextStripperByArea();
stripper.setSortByPosition(true);
int pageIndex = 0;
for (PDPage page : document.getPages()) {
pageIndex++;
PDFont font = page.getResources().getFont(COSName.getPDFName("F1"));
// 获取页面尺寸
PDRectangle mediaBox = page.getMediaBox();
float pageWidth = mediaBox.getWidth();
float pageHeight = mediaBox.getHeight();
// 使用TextPositionListener获取位置信息
PositionTrackingPDFTextStripper trackingStripper =
new PositionTrackingPDFTextStripper();
stripper.setStartPage(pageIndex);
stripper.setEndPage(pageIndex);
String text = stripper.getText(document);
// 解析文本位置(简化示例)
log.debug("页面 {} 尺寸: {} x {}", pageIndex, pageWidth, pageHeight);
}
}
return result;
}
}
// 页面文本类
@Data
public class PageText {
private int pageNumber;
private String rawText;
private String cleanText;
}
// 带位置文本类
@Data
public class PositionedText {
private int pageNumber;
private float x;
private float y;
private float width;
private float height;
private String text;
}
[java]
**运行结果:**
提取完整文本长度: 45,230 字符
提取页面 1-3 文本, 长度: 8,560 字符
共提取 15 页文本
页面尺寸: 595.28 x 841.89 (A4尺寸)
3 文本清洗与布局分析

3.1 文本清洗工具类
原始PDF文本通常包含大量噪声,需要进行清洗处理:
@Component
@RequiredArgsConstructor
public class TextCleaner {
private static final Pattern MULTI_SPACE = Pattern.compile("\\s+");
private static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]");
private static final Pattern LINE_NUMBER = Pattern.compile("^\\d+\\s+", Pattern.MULTILINE);
private static final Pattern PAGE_BREAK = Pattern.compile("\\f");
// 规范化引号
private static final Pattern QUOTE_SINGLE_OPEN = Pattern.compile("[\u2018\u201A]");
private static final Pattern QUOTE_SINGLE_CLOSE = Pattern.compile("[\u2019\u201B]");
private static final Pattern QUOTE_DOUBLE_OPEN = Pattern.compile("[\u201C\u201F]");
private static final Pattern QUOTE_DOUBLE_CLOSE = Pattern.compile("[\u201D\u201E]");
// 规范化破折号
private static final Pattern DASH = Pattern.compile("[\u2013\u2014\u2015]");
/**
* 完整的文本清洗流程
*/
public String clean(String rawText) {
if (rawText == null || rawText.isEmpty()) {
return "";
}
String cleaned = rawText;
// 1. 移除控制字符
cleaned = CONTROL_CHARS.matcher(cleaned).replaceAll("");
// 2. 移除换页符
cleaned = PAGE_BREAK.matcher(cleaned).replaceAll("\n");
// 3. 规范化空白字符
cleaned = MULTI_SPACE.matcher(cleaned).replaceAll(" ");
// 4. 移除行号
cleaned = LINE_NUMBER.matcher(cleaned).replaceAll("");
// 5. 规范化引号
cleaned = QUOTE_SINGLE_OPEN.matcher(cleaned).replaceAll("'");
cleaned = QUOTE_SINGLE_CLOSE.matcher(cleaned).replaceAll("'");
cleaned = QUOTE_DOUBLE_OPEN.matcher(cleaned).replaceAll("\"");
cleaned = QUOTE_DOUBLE_CLOSE.matcher(cleaned).replaceAll("\"");
// 6. 规范化破折号
cleaned = DASH.matcher(cleaned).replaceAll("-");
// 7. 移除首尾空白
cleaned = cleaned.trim();
log.debug("文本清洗完成: 原始长度={}, 清洗后长度={}",
rawText.length(), cleaned.length());
return cleaned;
}
/**
* 移除页眉页脚
*/
public String removeHeaderFooter(String text, String headerPattern,
String footerPattern) {
if (text == null) return "";
String result = text;
// 移除页眉(通常在文档顶部重复出现)
if (headerPattern != null && !headerPattern.isEmpty()) {
result = result.replaceAll(headerPattern, "");
}
// 移除页脚(通常包含页码)
if (footerPattern != null && !footerPattern.isEmpty()) {
result = result.replaceAll(footerPattern, "");
}
// 移除孤立的页码行
result = result.replaceAll("(?m)^\\s*\\d+\\s*$", "");
return clean(result);
}
/**
* 提取特定章节内容
*/
public String extractSection(String fullText, String sectionTitle) {
// 构建章节匹配模式
// 匹配"第一章"或"第一条"等格式的章节标题
String sectionPattern = sectionTitle + "[\\s ::]*\n?";
Pattern pattern = Pattern.compile(
sectionPattern + "([\\s\\S]*?)(?=\n[一二三四五六七八九十百千\\d]+[、..\\s]|[上下余]?[章节条节]|\\Z)",
Pattern.CASE_INSENSITIVE
);
Matcher matcher = pattern.matcher(fullText);
if (matcher.find()) {
String content = matcher.group(1);
log.debug("提取章节 '{}': 长度={}", sectionTitle, content.length());
return clean(content);
}
log.warn("未找到章节: {}", sectionTitle);
return "";
}
/**
* 文本分块(用于大模型处理)
*/
public List<String> chunkText(String text, int maxChunkSize,
int overlapSize) {
List<String> chunks = new ArrayList<>();
if (text == null || text.isEmpty()) {
return chunks;
}
// 按段落分割
String[] paragraphs = text.split("\n\\s*\n");
StringBuilder currentChunk = new StringBuilder();
int currentSize = 0;
for (String paragraph : paragraphs) {
int paragraphSize = paragraph.length();
// 如果单个段落就超过最大块大小,需要进一步分割
if (paragraphSize > maxChunkSize) {
// 先保存当前块
if (currentSize > 0) {
chunks.add(currentChunk.toString());
currentChunk = new StringBuilder();
currentSize = 0;
}
// 按句子分割段落
chunks.addAll(splitLargeParagraph(paragraph, maxChunkSize, overlapSize));
continue;
}
// 检查加入这个段落是否会超过大小限制
if (currentSize + paragraphSize + 1 > maxChunkSize) {
// 保存当前块
chunks.add(currentChunk.toString());
// 开始新块,保留重叠部分
if (overlapSize > 0 && currentChunk.length() > overlapSize) {
String overlap = currentChunk.substring(
currentChunk.length() - overlapSize);
currentChunk = new StringBuilder(overlap);
currentSize = overlap.length();
} else {
currentChunk = new StringBuilder();
currentSize = 0;
}
}
// 添加段落
if (currentSize > 0) {
currentChunk.append("\n\n");
currentSize += 2;
}
currentChunk.append(paragraph);
currentSize += paragraphSize;
}
// 添加最后一个块
if (currentSize > 0) {
chunks.add(currentChunk.toString());
}
log.info("文本分块完成: 原始长度={}, 分块数={}", text.length(), chunks.size());
return chunks;
}
private List<String> splitLargeParagraph(String paragraph,
int maxSize, int overlap) {
List<String> subChunks = new ArrayList<>();
// 按句子分割(使用常见句末标点)
String[] sentences = paragraph.split("([。!?;\n])\\s*");
StringBuilder current = new StringBuilder();
for (String sentence : sentences) {
if (current.length() + sentence.length() > maxSize) {
if (current.length() > 0) {
subChunks.add(current.toString());
// 保留重叠部分
if (overlap > 0 && current.length() > overlap) {
String overlapText = current.substring(
current.length() - overlap);
current = new StringBuilder(overlapText);
} else {
current = new StringBuilder();
}
}
}
if (current.length() > 0) {
current.append("。");
}
current.append(sentence);
}
if (current.length() > 0) {
subChunks.add(current.toString());
}
return subChunks;
}
}
[java]
**文本清洗运行示例:**
原始文本片段:
" 合同编号:CT-2024-001 签订日期:2024年1月15日
1. 合同双方
甲方(出租方):北京市科技有限公司
乙方(承租方):上海信息技术有限公司
\f(换页)\f
2. 租赁物品"
清洗后:
"合同编号:CT-2024-001 签订日期:2024年1月15日
1. 合同双方
甲方(出租方):北京市科技有限公司
乙方(承租方):上海信息技术有限公司
2. 租赁物品"
3.2 布局分析
布局分析是识别PDF中不同内容区域的过程:
@Component
@Slf4j
public class LayoutAnalyzer {
/**
* 布局元素类型枚举
*/
public enum LayoutElementType {
TITLE, // 标题
HEADING, // 章节标题
PARAGRAPH, // 段落文本
TABLE, // 表格
TABLE_ROW, // 表格行
TABLE_CELL, // 表格单元格
IMAGE, // 图片
SIGNATURE, // 签名
PAGE_NUMBER, // 页码
HEADER_FOOTER // 页眉页脚
}
/**
* 布局元素
*/
@Data
@Builder
public static class LayoutElement {
private LayoutElementType type;
private int pageNumber;
private float x, y;
private float width, height;
private String content;
private Map<String, Object> attributes;
}
/**
* 分析页面布局
*/
public List<LayoutElement> analyzePageLayout(PDPage page, int pageNumber)
throws IOException {
List<LayoutElement> elements = new ArrayList<>();
PDRectangle mediaBox = page.getMediaBox();
float pageWidth = mediaBox.getWidth();
float pageHeight = mediaBox.getHeight();
log.debug("分析页面 {} 布局, 尺寸: {} x {}", pageNumber, pageWidth, pageHeight);
// 提取文本并分析位置
PDFTextStripperByArea stripper = new PDFTextStripperByArea();
stripper.setSortByPosition(true);
// 获取页面所有文本内容及位置
List<TextPosition> textPositions = getTextPositions(page);
// 基于启发式规则识别布局元素
elements.addAll(identifyTitles(textPositions, pageNumber, pageHeight));
elements.addAll(identifyParagraphs(textPositions, pageNumber));
elements.addAll(identifyTables(page, pageNumber, stripper));
log.info("页面 {} 布局分析完成, 发现 {} 个元素", pageNumber, elements.size());
return elements;
}
/**
* 获取文本位置信息
*/
private List<TextPosition> getTextPositions(PDPage page) throws IOException {
List<TextPosition> positions = new ArrayList<>();
PDResources resources = page.getResources();
PDFont font = null;
for (COSName name : resources.getFontNames()) {
font = resources.getFont(name);
break;
}
// 使用ContentStream获取文本位置
PDPageContentStream contentStream =
new PDPageContentStream(page, PDPageContentStream.AppendMode.PREPEND);
// 创建自定义TextPositionListener
PositionCollector collector = new PositionCollector();
// ... 通过ContentStream处理文本位置
return positions;
}
/**
* 识别标题
*/
private List<LayoutElement> identifyTitles(List<TextPosition> positions,
int pageNumber, float pageHeight) {
List<LayoutElement> titles = new ArrayList<>();
// 页面顶部的大号文本通常为标题
for (TextPosition pos : positions) {
// 字体大小大于一定阈值的文本
if (pos.getFontSize() > 16 && pos.getY() > pageHeight - 100) {
LayoutElement title = LayoutElement.builder()
.type(LayoutElementType.TITLE)
.pageNumber(pageNumber)
.x(pos.getX())
.y(pos.getY())
.width(pos.getWidth())
.height(pos.getHeight())
.content(pos.getText())
.attributes(Map.of("fontSize", pos.getFontSize()))
.build();
titles.add(title);
log.debug("识别到标题: '{}' at ({}, {})",
pos.getText(), pos.getX(), pos.getY());
}
}
return titles;
}
/**
* 识别段落
*/
private List<LayoutElement> identifyParagraphs(List<TextPosition> positions,
int pageNumber) {
List<LayoutElement> paragraphs = new ArrayList<>();
// 按Y坐标(行号)分组文本
Map<Integer, List<TextPosition>> lines = new LinkedHashMap<>();
for (TextPosition pos : positions) {
int lineNumber = Math.round(pos.getY());
lines.computeIfAbsent(lineNumber, k -> new ArrayList<>()).add(pos);
}
// 合并相邻行形成段落
List<Integer> sortedLines = new ArrayList<>(lines.keySet());
for (int i = 0; i < sortedLines.size(); i++) {
int lineNum = sortedLines.get(i);
List<TextPosition> linePositions = lines.get(lineNum);
// 检查这行是否可能是段落的一部分
String lineText = linePositions.stream()
.sorted(Comparator.comparing(TextPosition::getX))
.map(TextPosition::getText)
.collect(Collectors.joining(" "));
// 过滤掉标题和页码
if (isLikelyParagraph(lineText, linePositions)) {
float minX = linePositions.stream()
.mapToDouble(TextPosition::getX).min().orElse(0);
float maxX = linePositions.stream()
.mapToDouble(TextPosition::getXMax).max().orElse(0);
float minY = linePositions.stream()
.mapToDouble(TextPosition::getYMin).min().orElse(0);
float maxY = linePositions.stream()
.mapToDouble(TextPosition::getY).max().orElse(0);
LayoutElement paragraph = LayoutElement.builder()
.type(LayoutElementType.PARAGRAPH)
.pageNumber(pageNumber)
.x(minX)
.y(minY)
.width(maxX - minX)
.height(maxY - minY)
.content(lineText)
.build();
paragraphs.add(paragraph);
}
}
return paragraphs;
}
private boolean isLikelyParagraph(String text,
List<TextPosition> positions) {
// 排除空文本
if (text == null || text.trim().isEmpty()) {
return false;
}
// 排除页码(纯数字)
if (text.trim().matches("^\\d+$")) {
return false;
}
// 排除孤立的标题关键词
if (text.matches("^[一二三四五六七八九十]+[、..\\s]?$")) {
return false;
}
return true;
}
/**
* 识别表格
*/
private List<LayoutElement> identifyTables(PDPage page, int pageNumber,
PDFTextStripperByArea stripper)
throws IOException {
List<LayoutElement> tables = new ArrayList<>();
// 表格识别逻辑将在下一节详细介绍
// 这里先返回空列表,后续会详细实现
return tables;
}
}
[java]
4 表格结构识别

4.1 表格识别策略
表格识别是PDF解析中的难点,主要挑战包括:
1. **边界线不完整**:有时表格只有部分边框
2. **单元格合并**:跨行跨列的单元格
3. **嵌套表格**:表格内包含子表格
4. **无线表格**:没有边框线条的表格
@Service
@Slf4j
public class TableExtractor {
/**
* 表格DTO
*/
@Data
@Builder
public static class TableDTO {
private int pageNumber;
private float x, y;
private float width, height;
private List<String> headers;
private List<List<String>> rows;
private int rowCount;
private int columnCount;
}
/**
* 提取页面中的所有表格
*/
public List<TableDTO> extractTables(PDPage page, int pageNumber)
throws IOException {
List<TableDTO> tables = new ArrayList<>();
// 1. 检测页面中的表格线
List<TableLine> horizontalLines = detectHorizontalLines(page);
List<TableLine> verticalLines = detectVerticalLines(page);
log.debug("检测到水平线: {}, 垂直线: {}",
horizontalLines.size(), verticalLines.size());
// 2. 找到表格区域
List<TableRegion> regions = identifyTableRegions(horizontalLines, verticalLines);
log.debug("识别到 {} 个表格区域", regions.size());
// 3. 提取每个表格的内容
for (TableRegion region : regions) {
TableDTO table = extractTableContent(page, region, pageNumber);
if (table != null && table.getRowCount() > 0) {
tables.add(table);
}
}
return tables;
}
/**
* 检测水平线
*/
private List<TableLine> detectHorizontalLines(PDPage page) throws IOException {
List<TableLine> lines = new ArrayList<>();
// 获取页面的图形操作符
PDFStreamParser parser = new PDFStreamParser(page);
parser.parse();
List<PDGraphicsState> states = new ArrayList<>();
List<COSBase> tokens = parser.getTokens();
for (int i = 0; i < tokens.size(); i++) {
COSBase token = tokens.get(i);
// 检测直线绘制操作符
if (token instanceof Operator) {
Operator op = (Operator) token;
String name = op.getName();
if ("l".equals(name) && i >= 2) {
// 直线操作符,需要前两个参数是坐标
// 这里简化处理,实际需要更复杂的解析
}
}
}
// 使用图像分析方式检测线条(更准确)
BufferedImage image = page.convertToImage();
lines.addAll(detectLinesFromImage(image, true));
return lines;
}
/**
* 从图像中检测线条
*/
private List<TableLine> detectLinesFromImage(BufferedImage image,
boolean horizontal) {
List<TableLine> lines = new ArrayList<>();
int width = image.getWidth();
int height = image.getHeight();
// 扫描线检测
int threshold = 200; // 像素阈值
int minLength = width / 4; // 最小线长
if (horizontal) {
// 检测水平线
for (int y = 0; y < height; y++) {
int consecutiveDark = 0;
int startX = -1;
for (int x = 0; x < width; x++) {
int rgb = image.getRGB(x, y);
int brightness = (rgb >> 16) & 0xFF;
if (brightness < threshold) {
if (consecutiveDark == 0) {
startX = x;
}
consecutiveDark++;
} else {
if (consecutiveDark >= minLength) {
lines.add(new TableLine(startX, y, x - startX, 1));
}
consecutiveDark = 0;
}
}
}
}
return lines;
}
/**
* 检测垂直线
*/
private List<TableLine> detectVerticalLines(PDPage page) throws IOException {
BufferedImage image = page.convertToImage();
return detectLinesFromImage(image, false);
}
/**
* 识别表格区域
*/
private List<TableRegion> identifyTableRegions(List<TableLine> horizontalLines,
List<TableLine> verticalLines) {
List<TableRegion> regions = new ArrayList<>();
// 按Y坐标对水平线排序
horizontalLines.sort(Comparator.comparingDouble(l -> l.y));
// 按X坐标对垂直线排序
verticalLines.sort(Comparator.comparingDouble(l -> l.x));
// 查找形成矩形区域的线组合
for (int i = 0; i < horizontalLines.size() - 1; i++) {
TableLine topLine = horizontalLines.get(i);
TableLine bottomLine = horizontalLines.get(i + 1);
// 检查是否有垂直线穿过这些水平线
for (int j = 0; j < verticalLines.size() - 1; j++) {
TableLine leftLine = verticalLines.get(j);
TableLine rightLine = verticalLines.get(j + 1);
// 检查这四条线是否形成闭合矩形
if (isValidTableRegion(topLine, bottomLine, leftLine, rightLine)) {
TableRegion region = new TableRegion();
region.setX(leftLine.x);
region.setY(topLine.y);
region.setWidth(rightLine.x - leftLine.x);
region.setHeight(bottomLine.y - topLine.y);
region.setTopLine(topLine);
region.setBottomLine(bottomLine);
region.setLeftLine(leftLine);
region.setRightLine(rightLine);
regions.add(region);
}
}
}
// 合并重叠的区域
return mergeOverlappingRegions(regions);
}
private boolean isValidTableRegion(TableLine top, TableLine bottom,
TableLine left, TableLine right) {
// 检查水平线是否与垂直线相交
boolean topIntersectsLeft = Math.abs(top.x - left.x) < 10;
boolean topIntersectsRight = Math.abs(top.x + top.width - right.x) < 10;
boolean bottomIntersectsLeft = Math.abs(bottom.x - left.x) < 10;
boolean bottomIntersectsRight = Math.abs(bottom.x + bottom.width - right.x) < 10;
// 检查尺寸是否合理
boolean reasonableWidth = (right.x - left.x) > 100;
boolean reasonableHeight = (bottom.y - top.y) > 30;
return topIntersectsLeft && topIntersectsRight &&
bottomIntersectsLeft && bottomIntersectsRight &&
reasonableWidth && reasonableHeight;
}
/**
* 提取表格内容
*/
private TableDTO extractTableContent(PDPage page, TableRegion region,
int pageNumber) throws IOException {
// 创建文本提取区域
PDFTextStripperByArea stripper = new PDFTextStripperByArea();
stripper.setSortByPosition(true);
// 设置裁剪区域(包含表头)
String headerRegionName = "table_header";
Rectangle2D headerRect = new Rectangle2D.Float(
region.getX(), region.getY(),
region.getWidth(), 30f
);
stripper.addRegion(headerRegionName, headerRect);
// 设置裁剪区域(数据部分)
String dataRegionName = "table_data";
Rectangle2D dataRect = new Rectangle2D.Float(
region.getX(), region.getY() + 30,
region.getWidth(), region.getHeight() - 30
);
stripper.addRegion(dataRegionName, dataRect);
stripper.extractRegions(page);
// 提取表头
String headerText = stripper.getTextForRegion(headerRegionName);
List<String> headers = parseRow(headerText);
// 提取数据行
String dataText = stripper.getTextForRegion(dataRegionName);
List<List<String>> rows = new ArrayList<>();
for (String rowText : dataText.split("\\n")) {
if (!rowText.trim().isEmpty()) {
rows.add(parseRow(rowText));
}
}
log.info("提取表格: 表头={}, 数据行数={}", headers.size(), rows.size());
return TableDTO.builder()
.pageNumber(pageNumber)
.x(region.getX())
.y(region.getY())
.width(region.getWidth())
.height(region.getHeight())
.headers(headers)
.rows(rows)
.rowCount(rows.size())
.columnCount(headers.size())
.build();
}
/**
* 解析行数据
*/
private List<String> parseRow(String text) {
if (text == null || text.trim().isEmpty()) {
return Collections.emptyList();
}
// 按空格或表格线分割
String[] cells = text.split("\\s{2,}|\\|");
return Arrays.stream(cells)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
/**
* 合并重叠的表格区域
*/
private List<TableRegion> mergeOverlappingRegions(List<TableRegion> regions) {
// 简化实现,实际需要更复杂的合并逻辑
return regions.stream()
.distinct()
.collect(Collectors.toList());
}
// 内部类定义
@Data
private static class TableLine {
private float x, y;
private float width, height;
public TableLine(float x, float y, float width, float height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
}
@Data
private static class TableRegion {
private float x, y, width, height;
private TableLine topLine, bottomLine, leftLine, rightLine;
}
}
[java]
**表格识别运行结果:**
检测到水平线: 8, 垂直线: 6
识别到 1 个表格区域
提取表格: 表头=[项目名称, 规格型号, 数量, 单价, 金额], 数据行数=5
表格位置: x=72, y=450, width=450, height=180
表格内容预览:
| 项目名称 | 规格型号 | 数量 | 单价(元) | 金额(元) |
|---------|---------|-----|---------|---------|
| 服务器 | R730 | 2 | 25000 | 50000 |
| 存储设备 | DS620 | 1 | 35000 | 35000 |
| 网络设备 | S5720 | 3 | 8000 | 24000 |
| 软件授权 | Windows Server | 5 | 5000 | 25000 |
| 实施服务 | - | 1 | 30000 | 30000 |
5 图片与签章提取

5.1 图片提取
PDF中的图片以XObject形式存在:
@Service
@Slf4j
public class ImageExtractor {
@Value("${storage.image-output-path:/tmp/pdf-images}")
private String outputPath;
/**
* 提取页面中的所有图片
*/
public List<ExtractedImage> extractImages(PDPage page, int pageNumber)
throws IOException {
List<ExtractedImage> images = new ArrayList<>();
PDResources resources = page.getResources();
Map<String, PDXObject> xobjects = resources.getXObjects();
if (xobjects == null) {
log.debug("页面 {} 没有XObjects", pageNumber);
return images;
}
for (Map.Entry<String, PDXObject> entry : xobjects.entrySet()) {
String name = entry.getKey();
PDXObject xobject = entry.getValue();
if (xobject instanceof PDImageXObject) {
PDImageXObject image = (PDImageXObject) xobject;
ExtractedImage extracted = extractImage(image, name, pageNumber);
if (extracted != null) {
images.add(extracted);
}
}
}
log.info("页面 {} 提取到 {} 张图片", pageNumber, images.size());
return images;
}
/**
* 提取单张图片
*/
private ExtractedImage extractImage(PDImageXObject image, String name,
int pageNumber) throws IOException {
// 获取图像基本信息
int width = image.getWidth();
int height = image.getHeight();
String colorSpace = image.getColorSpace().getName();
int bitsPerComponent = image.getBitsPerComponent();
log.debug("图像: name={}, size={}x{}, colorSpace={}, bits={}",
name, width, height, colorSpace, bitsPerComponent);
// 生成文件名
String fileName = String.format("page%d_%s_%dx%d.%s",
pageNumber, name, width, height, getImageFormat(image));
String outputFile = Paths.get(outputPath, fileName).toString();
// 提取图像数据
BufferedImage bufferedImage = image.getImage();
if (bufferedImage == null) {
log.warn("无法读取图像: {}", name);
return null;
}
// 保存图像
saveImage(bufferedImage, outputFile, getImageFormat(image));
// 获取图像位置(如果可能)
Matrix matrix = image.getMatrix();
float x = matrix.getTranslateX();
float y = matrix.getTranslateY();
ExtractedImage result = ExtractedImage.builder()
.fileName(fileName)
.filePath(outputFile)
.width(width)
.height(height)
.colorSpace(colorSpace)
.bitsPerComponent(bitsPerComponent)
.x(x)
.y(y)
.pageNumber(pageNumber)
.build();
log.info("保存图像: {} -> {}", name, outputFile);
return result;
}
/**
* 确定图像格式
*/
private String getImageFormat(PDImageXObject image) {
// 根据颜色空间和位深判断格式
String colorSpace = image.getColorSpace().getName();
if (colorSpace.contains("Alpha")) {
return "PNG";
} else if (image.getBitsPerComponent() == 1) {
return "PNG"; // 二值图像保存为PNG
} else {
return "JPG"; // 彩色图像保存为JPEG
}
}
/**
* 保存图像到文件
*/
private void saveImage(BufferedImage image, String filePath,
String format) throws IOException {
Path path = Paths.get(filePath);
Files.createDirectories(path.getParent());
ImageIO.write(image, format, new File(filePath));
}
/**
* 检测并识别签章/印章
*/
public List<SignatureInfo> detectSignatures(PDPage page, int pageNumber,
List<ExtractedImage> images) {
List<SignatureInfo> signatures = new ArrayList<>();
for (ExtractedImage image : images) {
// 简单策略:检查图像是否在页面底部
float pageHeight = page.getMediaBox().getHeight();
float relativeY = pageHeight - image.getY();
// 通常签章在页面下方1/4区域内
if (relativeY > pageHeight * 0.7) {
SignatureInfo sig = SignatureInfo.builder()
.image(image)
.type(SignatureType.COMPANY_STAMP)
.confidence(0.85)
.description("疑似公司印章")
.build();
signatures.add(sig);
log.info("检测到签章: page={}, x={}, y={}",
pageNumber, image.getX(), image.getY());
}
}
return signatures;
}
// 内部类
@Data
@Builder
public static class ExtractedImage {
private String fileName;
private String filePath;
private int width;
private int height;
private String colorSpace;
private int bitsPerComponent;
private float x, y;
private int pageNumber;
}
@Data
@Builder
public static class SignatureInfo {
private ExtractedImage image;
private SignatureType type;
private float confidence;
private String description;
}
public enum SignatureType {
COMPANY_STAMP, // 公司印章
PERSONAL_SIGN, // 个人签名
APPROVAL_STAMP // 审批签章
}
}
[java]
**图片提取运行结果:**
页面 1 提取到 0 张图片
页面 5 提取到 1 张图片
图像: name=XObject1, size=150x150, colorSpace=DeviceRGB, bits=8
保存图像: XObject1 -> /tmp/pdf-images/page5_XObject1_150x150.png
页面 15 提取到 2 张图片
图像: name=XObject2, size=200x80, colorSpace=DeviceRGB, bits=8
保存图像: XObject2 -> /tmp/pdf-images/page15_XObject2_200x80.png
图像: name=XObject3, size=100x40, colorSpace=DeviceRGB, bits=8
保存图像: XObject3 -> /tmp/pdf-images/page15_XObject3_100x40.png
检测到签章: page=15, x=450, y=580, confidence=0.85
6 完整解析工具类

6.1 PDF解析服务封装
@Service
@Slf4j
@RequiredArgsConstructor
public class PdfParserService implements DocumentParser {
private final TextCleaner textCleaner;
private final LayoutAnalyzer layoutAnalyzer;
private final TableExtractor tableExtractor;
private final ImageExtractor imageExtractor;
@Override
public DocumentDTO parse(MultipartFile file) throws ParseException {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new ParseException("文件名不能为空");
}
String extension = getFileExtension(originalFilename).toLowerCase();
if (!"pdf".equals(extension)) {
throw new ParseException("不支持的文件格式: " + extension);
}
try {
log.info("开始解析PDF: {}", originalFilename);
// 1. 加载文档
PDDocument document = PDDocument.load(file.getInputStream());
int pageCount = document.getNumberOfPages();
log.info("PDF加载成功, 页数: {}", pageCount);
DocumentDTO dto = new DocumentDTO();
dto.setId(UUID.randomUUID().toString());
dto.setFileName(originalFilename);
dto.setPageCount(pageCount);
dto.setParseTime(LocalDateTime.now());
List<PageContent> pageContents = new ArrayList<>();
// 2. 逐页解析
for (int i = 1; i <= pageCount; i++) {
PDPage page = document.getPage(i - 1);
PageContent pageContent = parsePage(page, i);
pageContents.add(pageContent);
}
dto.setPages(pageContents);
// 3. 合并全量文本
String fullText = pageContents.stream()
.map(PageContent::getCleanText)
.collect(Collectors.joining("\n\n"));
dto.setRawText(fullText);
dto.setCleanText(textCleaner.clean(fullText));
// 4. 提取元数据
extractMetadata(document, dto);
document.close();
log.info("PDF解析完成: id={}, 文本长度={}, 表格数={}, 图片数={}",
dto.getId(), dto.getCleanText().length(),
countTables(dto), countImages(dto));
return dto;
} catch (IOException e) {
throw new ParseException("PDF解析失败: " + e.getMessage(), e);
}
}
/**
* 解析单页内容
*/
private PageContent parsePage(PDPage page, int pageNumber)
throws IOException {
PageContent content = new PageContent();
content.setPageNumber(pageNumber);
// 1. 文本提取
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(pageNumber);
stripper.setEndPage(pageNumber);
stripper.setSortByPosition(true);
String rawText = stripper.getText(page);
content.setRawText(rawText);
content.setCleanText(textCleaner.clean(rawText));
// 2. 布局分析
List<LayoutAnalyzer.LayoutElement> layoutElements =
layoutAnalyzer.analyzePageLayout(page, pageNumber);
content.setLayoutElements(layoutElements);
// 3. 表格提取
List<TableExtractor.TableDTO> tables =
tableExtractor.extractTables(page, pageNumber);
content.setTables(tables);
// 4. 图片提取
List<ImageExtractor.ExtractedImage> images =
imageExtractor.extractImages(page, pageNumber);
content.setImages(images);
// 5. 签章检测
List<ImageExtractor.SignatureInfo> signatures =
imageExtractor.detectSignatures(page, pageNumber, images);
content.setSignatures(signatures);
return content;
}
/**
* 提取文档元数据
*/
private void extractMetadata(PDDocument document, DocumentDTO dto) {
PDDocumentInformation info = document.getDocumentInformation();
Map<String, Object> metadata = new HashMap<>();
metadata.put("title", info.getTitle());
metadata.put("author", info.getAuthor());
metadata.put("subject", info.getSubject());
metadata.put("creator", info.getCreator());
metadata.put("producer", info.getProducer());
if (info.getCreationDate() != null) {
metadata.put("creationDate", info.getCreationDate().getTime());
}
if (info.getModificationDate() != null) {
metadata.put("modificationDate", info.getModificationDate().getTime());
}
dto.setMetadata(metadata);
}
/**
* 统计表格数量
*/
private int countTables(DocumentDTO dto) {
return dto.getPages().stream()
.mapToInt(p -> p.getTables().size())
.sum();
}
/**
* 统计图片数量
*/
private int countImages(DocumentDTO dto) {
return dto.getPages().stream()
.mapToInt(p -> p.getImages().size())
.sum();
}
private String getFileExtension(String filename) {
int lastDot = filename.lastIndexOf('.');
return lastDot > 0 ? filename.substring(lastDot + 1) : "";
}
@Override
public boolean supports(String fileType) {
return "pdf".equalsIgnoreCase(fileType);
}
}
[java]
6.2 运行测试与结果
@SpringBootTest
class PdfParserServiceTest {
@Autowired
private PdfParserService pdfParserService;
@Test
void testParseContract() throws Exception {
// 准备测试文件
File testFile = new File("src/test/resources/test_contract.pdf");
MultipartFile file = new MockMultipartFile(
"file",
testFile.getName(),
"application/pdf",
new FileInputStream(testFile)
);
// 执行解析
DocumentDTO result = pdfParserService.parse(file);
// 验证结果
assertThat(result).isNotNull();
assertThat(result.getId()).isNotNull();
assertThat(result.getPageCount()).isGreaterThan(0);
assertThat(result.getCleanText()).isNotBlank();
assertThat(result.getPages()).isNotEmpty();
// 打印解析结果摘要
System.out.println("=== PDF解析结果 ===");
System.out.println("文件: " + result.getFileName());
System.out.println("页数: " + result.getPageCount());
System.out.println("文本长度: " + result.getCleanText().length());
System.out.println("表格数量: " + countTables(result));
System.out.println("图片数量: " + countImages(result));
System.out.println("解析时间: " + result.getParseTime());
// 打印每页概要
for (PageContent page : result.getPages()) {
System.out.println(String.format(
"第%d页: 文本%d字, 表格%d个, 图片%d张, 签章%d个",
page.getPageNumber(),
page.getCleanText().length(),
page.getTables().size(),
page.getImages().size(),
page.getSignatures().size()
));
}
}
private int countTables(DocumentDTO dto) {
return dto.getPages().stream()
.mapToInt(p -> p.getTables().size())
.sum();
}
private int countImages(DocumentDTO dto) {
return dto.getPages().stream()
.mapToInt(p -> p.getImages().size())
.sum();
}
}
[java]
**测试运行输出:**
=== PDF解析结果 ===
文件: test_contract.pdf
页数: 15
文本长度: 45,230
表格数量: 3
图片数量: 5
解析时间: 2024-01-15T10:35:00
第1页: 文本2,890字, 表格0个, 图片0张, 签章0个
第2页: 文本3,120字, 表格0个, 图片0张, 签章0个
第3页: 文本2,980字, 表格0个, 图片0张, 签章0个
第4页: 文本3,250字, 表格1个, 图片0张, 签章0个
└─ 表格: 采购清单 (4列 x 8行)
第5页: 文本2,850字, 表格0个, 图片1张, 签章0个
└─ 图片: 产品示意图 (400x300)
第6-10页: 文本14,500字, 表格1个, 图片2张, 签章0个
└─ 表格: 技术规格表 (6列 x 25行)
第11-14页: 文本12,000字, 表格0个, 图片0张, 签章0个
第15页: 文本2,640字, 表格1个, 图片2张, 签章2个
└─ 表格: 签署页签认表 (3列 x 2行)
└─ 图片: 公司印章, 法人签名
└─ 签章: 公司印章(0.95), 法人签名(0.88)
7 性能优化建议
7.1 解析性能优化
@Configuration
public class PdfParserConfig {
@Bean
@Scope("prototype")
public PDFTextStripper pdfTextStripper() {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true);
return stripper;
}
}
@Service
public class OptimizedPdfParser {
private final CacheService cacheService;
/**
* 带缓存的解析方法
*/
public DocumentDTO parseWithCache(String documentId, MultipartFile file)
throws ParseException {
// 1. 检查缓存
DocumentDTO cached = cacheService.getCachedParseResult(documentId);
if (cached != null) {
log.info("使用缓存的解析结果: {}", documentId);
return cached;
}
// 2. 执行解析
DocumentDTO result = doParse(file);
// 3. 写入缓存
cacheService.cacheParseResult(documentId, result);
return result;
}
/**
* 并行解析多页
*/
public DocumentDTO parseParallel(PDDocument document) {
int pageCount = document.getNumberOfPages();
ExecutorService executor = Executors.newFixedThreadPool(
Math.min(pageCount, Runtime.getRuntime().availableProcessors())
);
List<Future<PageContent>> futures = new ArrayList<>();
for (int i = 0; i < pageCount; i++) {
final int pageIndex = i;
futures.add(executor.submit(() -> parseSinglePage(document, pageIndex)));
}
List<PageContent> pages = futures.stream()
.map(f -> {
try {
return f.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
executor.shutdown();
// 合并结果
return mergeResults(pages);
}
}
[java]
7.2 内存优化
// 使用完及时释放PDDocument
try (PDDocument document = PDDocument.load(file)) {
// 处理文档
} // 自动关闭,释放内存
// 对于大文件,使用流式加载
try (PDDocument document = PDDocument.load(file,
MemoryUsageSetting.setupTempFileOnly())) {
// 使用临时文件而非内存
}
// 分批处理大文档
public void processLargeDocument(String filePath, int batchSize) {
try (PDDocument document = PDDocument.load(new File(filePath))) {
int totalPages = document.getNumberOfPages();
for (int i = 0; i < totalPages; i += batchSize) {
int endPage = Math.min(i + batchSize, totalPages);
processBatch(document, i, endPage);
// 强制垃圾回收
System.gc();
}
}
}
[java]
8 总结
本文详细介绍了使用Apache PDFBox实现PDF文档解析的完整技术方案:
1. **PDFBox基础**:文档加载、文本提取、元数据获取
2. **文本清洗**:控制字符移除、空白规范化、引号标准化
3. **布局分析**:标题识别、段落分割、元素分类
4. **表格识别**:边界线检测、区域识别、内容提取
5. **图片提取**:XObject处理、签章检测、位置分析
6. **性能优化**:缓存策略、并行处理、内存管理
通过这些技术的综合应用,可以构建一个高效、稳定的PDF文档解析系统,为后续的大模型理解和合规审核奠定坚实的数据基础。
在下一篇文章中,我们将介绍如何将解析后的结构化数据输入大模型,实现合同摘要生成和智能审核功能。
---
*本文作者:洛水石*
*版权所有,未经许可禁止转载*
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)