PDF 表格解析复杂场景知识沉淀
适用于政府报告、统计月报、监测数据等结构化 PDF 的自动化提取(对上一篇PDF解析知识的拓展)
一、单元格合并的三种形态
| 合并类型 | 视觉表现 | PDF 底层真相 | 处理策略 |
|---|---|---|---|
| 行内合并(横向) | 一个单元格跨多列 | 该位置只画一次文字,无竖线分隔 | 检测列边界缺失,将多列内容合并 |
| 列内合并(纵向) | 一个单元格跨多行 | 该位置只在第一行画文字,后续行该列为空 | 检测 Y 坐标连续性,向下填充直到新记录开始 |
| 行列交叉合并 | 大块区域合并 | 既无横线也无竖线,文字只出现一次 | 结合 X/Y 双向检测,标记为合并区域 |
关键难点:PDF 没有"合并单元格"的语义,只有"某些位置没画文字"。程序无法区分是"合并"还是"数据缺失"。
二、单元格内容多行(最隐蔽的问题)
2.1 视觉表现
一个单元格里文字换行了,比如:
┌─────────┐
│ 贵阳市 │
│ 南明区 │
│ 云岩区 │
└─────────┘
2.2 PDF 底层
实际上是 3 个独立的文字块,Y 坐标不同,但被框在同一个矩形内。
2.3 处理策略
- 第一步 :先按
extract_words提取所有文字块,记录每个块的(x0, y0, x1, y1) - 第二步:判断这些块是否属于同一个单元格 ------ 看它们的 X 范围是否高度重叠(重叠度 > 80%)
- 第三步 :同一单元格内的多行内容,用换行符
\n拼接,或按业务规则合并(如"贵阳市\n南明区" → "贵阳市南明区")
场景关联:水源地名称可能出现"原北郊水库\n(备用)"这种多行备注,需要识别为同一个单元格。
三、跨页表格的处理
3.1 跨页的两种模式
| 模式 | 特征 | 处理策略 |
|---|---|---|
| 续表模式 | 第二页有"续表"或"接上页"字样,有表头 | 检测到"续表"关键词后,将两页数据拼接,表头去重 |
| 无标识续页 | 第二页直接继续数据,无表头无标识 | 用记录边界检测(序号连续性)判断是否为同一表格的延续 |
3.2 跨页后表头的四种情况
| 情况 | 示例 | 处理策略 |
|---|---|---|
| 每页都有完整表头 | 第1页有表头,第2页也有表头 | 提取时识别表头行,去重或跳过 |
| 仅首页有表头 | 第1页有表头,第2页直接是数据 | 用首页表头做列定位,后续页直接按坐标对齐 |
| 续页有特殊表头 | 第2页表头简化为"续上表:XX月报" | 正则匹配"续"字,跳过该行 |
| 完全无表头 | 第2页直接是数据,无任何标识 | 最困难,需要用内容特征推断列语义 |
3.3 跨页数据拼接的关键逻辑
python
def merge_cross_page_tables(pages_data):
"""
跨页表格合并
"""
all_records = []
last_seq = 0
for page_idx, page_records in enumerate(pages_data):
if not page_records:
continue
# 检测是否为新表格开始(有序号重置)
first_seq = page_records[0].get("序号")
if first_seq and first_seq <= last_seq:
# 序号重置 = 新表格,不拼接
all_records.extend(page_records)
else:
# 序号连续 = 续表,拼接
all_records.extend(page_records)
# 更新最后序号
if page_records:
last_seq = page_records[-1].get("序号", 0)
return all_records
四、综合处理框架
PDF 输入
│
v
[Step 1] 逐页提取文字块 (extract_words)
│ └── 记录每个词的 (text, x0, y0, x1, y1, page_num)
│
v
[Step 2] 检测表格区域
│ ├── 有边框 → 用 lines 策略检测单元格边界
│ └── 无边框 → 用文字对齐 + 空白间隙推断列边界
│
v
[Step 3] 单元格重建
│ ├── 单格单词 → 直接赋值
│ ├── 单格多词(同行)→ X 重叠检测,合并为同一单元格
│ ├── 单格多行(同列)→ Y 连续性检测,用 \n 拼接
│ └── 合并单元格 → 标记为 merged,向下/向右填充
│
v
[Step 4] 记录边界检测
│ ├── 有序号 → 新记录开始(最可靠)
│ ├── 有城市名 → 新记录开始
│ └── 无标识 → 上一条记录的续行(合并到上一条)
│
v
[Step 5] 跨页处理
│ ├── 检测"续表"/"接上页"关键词
│ ├── 检测序号连续性(1,2,3... 不中断)
│ └── 拼接数据,去除重复表头
│
v
[Step 6] 后处理
├── 合并单元格内容填充(向下/向右)
├── 多行内容合并(按业务规则)
└── 输出结构化数据
五、关键代码片段
5.1 检测单元格内多行内容
python
def group_words_into_cells(words, col_boundaries):
"""
将文字块按单元格分组,处理多行内容
"""
cells = {}
for word in words:
# 找到该词属于哪一列
col_idx = find_column(word["x0"], col_boundaries)
# 找到该词属于哪一行(用锚点对齐,非简单Y聚类)
row_key = find_row_anchor(word, words)
key = (row_key, col_idx)
if key not in cells:
cells[key] = []
cells[key].append(word)
# 同一单元格内的词按 Y 排序,拼接
result = {}
for (row, col), word_list in cells.items():
word_list.sort(key=lambda w: w["top"])
text = "\n".join([w["text"] for w in word_list])
result[(row, col)] = text
return result
5.2 合并单元格检测与填充
python
def detect_and_fill_merged_cells(table_data):
"""
检测合并单元格并填充
"""
# 向下填充(列合并)
for col in range(len(table_data[0])):
last_value = None
for row in range(len(table_data)):
if table_data[row][col]:
last_value = table_data[row][col]
elif last_value:
# 当前为空且上方有值 → 可能是合并单元格
table_data[row][col] = last_value
# 向右填充(行合并)------ 视业务需要
# ...
return table_data
5.3 跨页连续性检测
python
def is_continuation_page(current_page, next_page):
"""
判断下一页是否是当前表格的续页
"""
# 方法1:检测序号连续性
current_last_seq = get_last_sequence(current_page)
next_first_seq = get_first_sequence(next_page)
if next_first_seq and current_last_seq:
return next_first_seq == current_last_seq + 1
# 方法2:检测"续"关键词
next_header = extract_header(next_page)
if "续" in next_header or "接上页" in next_header:
return True
# 方法3:列数一致且格式相似
return len(current_page[0]) == len(next_page[0])
六、校验清单
处理复杂场景后必须检查:
| 校验项 | 方法 | 失败处理 |
|---|---|---|
| 序号连续性 | set(range(1, n+1)) - set(实际序号) |
标记缺失行,人工复核 |
| 城市名不重复(同一行) | 同一记录中城市名只出现一次 | 检测合并单元格是否未填充 |
| 列数一致性 | 所有记录列数相同 | 标记异常行,检查多行内容拆分 |
| 跨页后记录数 | 总记录数 = 各页之和 - 表头行数 | 检查表头去重逻辑 |
| 合并单元格填充 | 随机抽样检查填充值是否正确 | 标记异常,人工复核 |
七、一句话总结
PDF 表格解析的本质是"视觉还原工程":合并单元格是"空值填充",多行内容是"同格拼接",跨页是"连续性拼接",表头是"重复去重"。没有魔法,只有对坐标、空白、文字的精细工程。