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 跨页数据拼接的关键逻辑
defmerge_cross_page_tables(pages_data):""" 跨页表格合并 """all_records=[]last_seq=0forpage_idx,page_recordsinenumerate(pages_data):ifnotpage_records:continue# 检测是否为新表格开始(有序号重置)first_seq=page_records[0].get("序号")iffirst_seqandfirst_seq<=last_seq:# 序号重置 = 新表格,不拼接all_records.extend(page_records)else:# 序号连续 = 续表,拼接all_records.extend(page_records)# 更新最后序号ifpage_records:last_seq=page_records[-1].get("序号",0)returnall_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 检测单元格内多行内容
defgroup_words_into_cells(words,col_boundaries):""" 将文字块按单元格分组,处理多行内容 """cells={}forwordinwords:# 找到该词属于哪一列col_idx=find_column(word["x0"],col_boundaries)# 找到该词属于哪一行(用锚点对齐,非简单Y聚类)row_key=find_row_anchor(word,words)key=(row_key,col_idx)ifkeynotincells:cells[key]=[]cells[key].append(word)# 同一单元格内的词按 Y 排序,拼接result={}for(row,col),word_listincells.items():word_list.sort(key=lambdaw:w["top"])text="\n".join([w["text"]forwinword_list])result[(row,col)]=textreturnresult5.2 合并单元格检测与填充
defdetect_and_fill_merged_cells(table_data):""" 检测合并单元格并填充 """# 向下填充(列合并)forcolinrange(len(table_data[0])):last_value=Noneforrowinrange(len(table_data)):iftable_data[row][col]:last_value=table_data[row][col]eliflast_value:# 当前为空且上方有值 → 可能是合并单元格table_data[row][col]=last_value# 向右填充(行合并)—— 视业务需要# ...returntable_data5.3 跨页连续性检测
defis_continuation_page(current_page,next_page):""" 判断下一页是否是当前表格的续页 """# 方法1:检测序号连续性current_last_seq=get_last_sequence(current_page)next_first_seq=get_first_sequence(next_page)ifnext_first_seqandcurrent_last_seq:returnnext_first_seq==current_last_seq+1# 方法2:检测"续"关键词next_header=extract_header(next_page)if"续"innext_headeror"接上页"innext_header:returnTrue# 方法3:列数一致且格式相似returnlen(current_page[0])==len(next_page[0])六、校验清单
处理复杂场景后必须检查:
| 校验项 | 方法 | 失败处理 |
|---|---|---|
| 序号连续性 | set(range(1, n+1)) - set(实际序号) | 标记缺失行,人工复核 |
| 城市名不重复(同一行) | 同一记录中城市名只出现一次 | 检测合并单元格是否未填充 |
| 列数一致性 | 所有记录列数相同 | 标记异常行,检查多行内容拆分 |
| 跨页后记录数 | 总记录数 = 各页之和 - 表头行数 | 检查表头去重逻辑 |
| 合并单元格填充 | 随机抽样检查填充值是否正确 | 标记异常,人工复核 |
七、一句话总结
PDF 表格解析的本质是"视觉还原工程":合并单元格是"空值填充",多行内容是"同格拼接",跨页是"连续性拼接",表头是"重复去重"。没有魔法,只有对坐标、空白、文字的精细工程。