import pdfplumber
import pandas as pd
from typing import List, Dict, Tuple, Optional
def extract_table_by_title(
pdf_path: str,
target_title: str,
page_range: Tuple[int, int] = (1, None), # (起始页, 结束页),None 表示到最后一页
title_padding: float = 5, # 标题下方搜索表格的垂直间距(像素)
table_merge_threshold: float = 30, # 跨页表格合并的垂直阈值(像素)
header_match_tolerance: float = 0.8, # 表头匹配的相似度阈值(0-1)
debug: bool = False
) -> List[pd.DataFrame]:
"""
精准提取 PDF 中指定标题下方的表格,支持跨多页、复杂排版场景,保留空白行
:param pdf_path: PDF 文件路径
:param target_title: 要匹配的标题文本(支持模糊匹配)
:param page_range: 搜索的页码范围,(1, 5) 表示 1-5 页,第二值为 None 表示到最后一页
:param title_padding: 标题下方开始搜索表格的垂直距离(避免误判标题内的表格)
:param table_merge_threshold: 跨页表格合并的垂直阈值(判断是否为同一表格)
:param header_match_tolerance: 表头匹配的相似度阈值,用于判断跨页表格是否为同一表格的延续
:param debug: 是否输出调试信息(坐标、页码等)
:return: 提取到的表格列表(每个元素是 DataFrame)
"""
all_tables = []
current_table_rows: List[List[str]] = [] # 存储跨页收集的表格行(原始列表形式)
current_header: Optional[List[str]] = None # 存储当前表格的表头
title_found = False # 标记是否找到目标标题
title_bottom = 0.0 # 记录标题的底部 y 坐标(用于定位表格范围)
page_width = 0.0 # 记录页面宽度,用于辅助判断表格是否跨页
def _is_header_match(header1: List[str], header2: List[str]) -> bool:
"""判断两个表头是否匹配,基于内容相似度"""
if len(header1) != len(header2):
return False
match_count = 0
for h1, h2 in zip(header1, header2):
if h1.strip().lower() == h2.strip().lower():
match_count += 1
return match_count / len(header1) >= header_match_tolerance
with pdfplumber.open(pdf_path) as pdf:
# 处理页码范围
start_page = page_range[0]
end_page = page_range[1] if page_range[1] is not None else len(pdf.pages)
end_page = min(end_page, len(pdf.pages)) # 确保不超过实际页数
if start_page > end_page:
return all_tables
# 记录页面宽度(默认取第一页宽度,假设所有页宽度一致)
first_page = pdf.pages[0]
page_width = first_page.bbox[2] - first_page.bbox[0]
for page_num in range(start_page, end_page + 1):
page = pdf.pages[page_num - 1] # pdfplumber 内部页码从 0 开始
page_height = page.bbox[3] # 页面底部 y 坐标
page_text = page.extract_text()
if debug:
print(f"\n=== 处理第 {page_num} 页 ===")
# ------------- 1. 查找标题(未找到时执行)-------------
if not title_found:
# 遍历页面文本块,匹配标题(支持模糊包含)
words = page.extract_words()
for word_group in words:
if target_title in word_group["text"]:
title_found = True
title_bottom = word_group["bottom"] # 标题的底部坐标
if debug:
print(f"找到标题「{target_title}」,坐标: {word_group}")
# 计算当前页表格搜索范围:标题底部 + 间距 ~ 页面底部
table_search_bbox = (
page.bbox[0], # 左边界(页面最左)
title_bottom + title_padding, # 上边界(标题下方)
page.bbox[2], # 右边界(页面最右)
page_height # 下边界(页面最底)
)
if debug:
print(f"表格搜索范围: {table_search_bbox}")
# 提取标题下方区域的表格
cropped_page = page.within_bbox(table_search_bbox)
tables_in_area = cropped_page.extract_tables()
# 处理提取到的表格(优先选第一个有效表格)
for table in tables_in_area:
if len(table) == 0:
continue
# 第一行作为表头
current_header = [col.strip() for col in table[0]]
# 内容行(从第二行开始,保留空白行)
content_rows = table[1:] # 不再过滤空白行
current_table_rows.extend(content_rows)
# 检查表格是否跨页(表格底部接近页面底部 或 接近页面宽度)
table_bottom = cropped_page.bbox[3] if cropped_page.bbox else 0.0
table_width = cropped_page.bbox[2] - cropped_page.bbox[0] if cropped_page.bbox else 0.0
if (page_height - table_bottom) < table_merge_threshold or \
abs(table_width - page_width) < table_merge_threshold:
if debug:
print(f"第 {page_num} 页表格可能跨页,继续收集...")
else:
# 表格未跨页,直接生成 DataFrame
if current_table_rows:
df = pd.DataFrame(current_table_rows, columns=current_header)
all_tables.append(df)
current_table_rows = []
current_header = None
break # 找到标题下方第一个表格后跳出循环
continue # 找到标题后,当前页后续逻辑处理跨页表格
# ------------- 2. 处理跨页表格(标题已找到时执行)-------------
if title_found and current_header is not None:
# 提取当前页所有表格(判断是否是延续表格)
all_page_tables = page.extract_tables()
for table in all_page_tables:
if len(table) == 0:
continue
cleaned_table = [row for row in table] # 保留空白行,仅去除单元格空白(可选)
# 如需保留单元格空白,可直接使用 table:
# cleaned_table = table
if not cleaned_table:
continue
# 判断是否是当前表格的延续(通过表头匹配 / 无表头延续)
new_header = cleaned_table[0] if len(cleaned_table) > 0 else []
if _is_header_match(current_header, new_header):
# 遇到相同表头,说明是新表格,先保存当前表格
if current_table_rows:
df = pd.DataFrame(current_table_rows, columns=current_header)
all_tables.append(df)
current_table_rows = []
# 新表格的表头和内容
current_header = new_header
current_table_rows.extend(cleaned_table[1:])
else:
# 无相同表头,视为当前表格的延续(检查列数是否匹配)
if len(cleaned_table[0]) == len(current_header):
current_table_rows.extend(cleaned_table)
else:
if debug:
print(f"第 {page_num} 页表格列数不匹配,跳过:{cleaned_table}")
# 检查当前页是否是最后一页,或表格是否结束
table_bottom = page.bbox[3] if current_table_rows else 0.0
if page_num == end_page or (page_height - table_bottom) > table_merge_threshold:
if current_table_rows:
df = pd.DataFrame(current_table_rows, columns=current_header)
all_tables.append(df)
current_table_rows = []
current_header = None
return all_tables
# 演示
if __name__ == "__main__":
pdf_path = "test.pdf" # 替换为你的 PDF 文件路径
target_title = "员工数据" # 替换为要提取的标题
# 配置参数:根据 PDF 实际排版调整
tables = extract_table_by_title(
pdf_path,
target_title,
page_range=(1, None), # 从第 1 页搜索到最后一页
title_padding=2, # 标题下方较小间距,适配紧密排版
table_merge_threshold=20, # 跨页合并阈值,可根据实际调整
header_match_tolerance=0.6, # 表头匹配容忍度,允许部分差异
debug=True # 开启调试,输出详细信息
)
if tables:
for i, df in enumerate(tables):
print(f"\n=== 提取到第 {i+1} 个表格 ===")
print(df)
# 保存为 CSV(按需启用)
df.to_csv(f"extracted_table_{i+1}.csv", index=False, encoding="utf-8")
else:
print(f"未找到标题为「{target_title}」的表格")