Python批量提取PDF表格转Excel:pdfplumber与pandas实战
简介这份Python实例资源面向需要批量处理PDF表格的办公人员与数据分析初学者解决手动复制PDF表格到Excel效率低、易出错的问题。包内共14个文件以6张png运行截图、3个xlsx结果文件、3个pdf样例文档为主另含1个ipynb交互式笔记与1个py脚本压缩包约18.34MB覆盖从样例数据到可运行代码的完整链路。资源选用东旭蓝天、华特气体、保利地产等真实年报PDF作为测试对象演示如何借助pdfplumber或PyPDF2定位表格、用pandas构建DataFrame并清洗数据再通过openpyxl或xlsxwriter写入Excel同时给出图像型表格的OCR处理思路与异常排错方向。目前已有117人学习适合想掌握PDF表格提取与自动化办公技能的读者参考复用。1. 批量处理 PDF 表格这件事我踩过的坑比想象中多上个月帮一个做财务的朋友处理一批供应商对账单三百多份 PDF每份里面都嵌着两三张表格。他原本的做法是打开 PDF、截图、粘贴到 Excel、手动对齐列——一套流程下来一份文件要折腾五六分钟。我接手后写了个 Python 脚本把整个目录的 PDF 一次性跑完输出一个合并好的 Excel 文件全程不到两分钟。这件事让我意识到PDF 表格提取这个需求在财务、审计、行政、科研数据整理场景里几乎是刚需但很多人还在用最原始的方式硬扛。这个项目要解决的核心问题很明确把 PDF 里的表格结构识别出来转成 Excel 能直接用的行列数据。它适合两类人——一类是手头有大量 PDF 报表需要批量转 Excel 的办公人员另一类是想拿现成代码做二次开发、接入自己业务系统的开发者。技术栈上Python 生态里处理 PDF 表格提取的主流方案是 pdfplumber 负责解析、pandas 负责数据清洗和导出必要时用 openpyxl 做 Excel 格式控制。下面我从项目结构开始拆把能跑起来的代码和容易翻车的地方都讲清楚。2. 项目结构与环境搭建从零把骨架立起来2.1 目录组织与模块划分拿到一个 PDF 转 Excel 的项目第一件事不是急着写提取逻辑而是把目录结构定清楚。我一般会按职责拆成四个部分输入区放待处理的 PDF 文件输出区放生成的 Excel核心代码区放提取和导出的模块配置区放路径、表头行数、页码范围这些可调参数。这样做的目的是让非开发者也能通过改配置来适配自己的文件不用动核心代码。pdf_table_extractor/ ├── input_pdfs/ # 待处理的 PDF 文件目录 ├── output_excel/ # 生成的 Excel 输出目录 ├── config.py # 路径、表头行数、页码范围等配置 ├── extractor.py # PDF 表格提取核心逻辑 ├── exporter.py # 数据清洗与 Excel 导出 ├── main.py # 批量处理入口 └── requirements.txt # 依赖清单这个结构的好处是职责边界清晰。extractor.py 只负责从 PDF 里把表格数据抠出来exporter.py 只负责把数据整理成 DataFrame 并写 Excelmain.py 负责遍历目录、调度前两者。后面要换提取引擎或者改输出格式只需要动对应的文件不会牵一发动全身。2.2 依赖安装与版本选择依赖这块核心就三个库。pdfplumber 负责 PDF 解析和表格抽取pandas 负责数据处理openpyxl 负责 Excel 写入。我一般会固定版本号避免不同环境跑出不一样的结果。# requirements.txt pdfplumber0.10.3 pandas2.1.4 openpyxl3.1.2安装命令很简单pip install -r requirements.txt这里有个血泪经验pdfplumber 底层依赖 pdfminer.six不同版本对某些 PDF 的解析行为会有差异。如果你的 PDF 是扫描件或者用了特殊字体嵌入pdfplumber 可能直接抽不出表格结构这时候需要换思路——要么用 OCR 方案要么用 camelot 这类基于图像处理的库。但那是另一个话题了这个项目先聚焦在文本型 PDF 上。提示建议用虚拟环境安装依赖避免和系统里已有的 pandas 版本冲突。python -m venv venv 然后激活即可。2.3 配置文件的设计config.py 是整个项目的控制面板。我把所有可能需要调整的参数都集中在这里包括输入输出路径、表头占几行、要提取第几页的表格、是否合并多个 PDF 的结果到一个 Excel 等。# config.py import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) # 输入输出路径 INPUT_DIR os.path.join(BASE_DIR, input_pdfs) OUTPUT_DIR os.path.join(BASE_DIR, output_excel) OUTPUT_FILENAME extracted_tables.xlsx # 表格解析参数 HEADER_ROWS 1 # 表头占几行常见是1行复杂报表可能2-3行 PAGES all # 提取哪些页all 或 [0, 1, 2] 这样的列表 MERGE_ALL True # 是否把所有 PDF 的表格合并到一个 sheet # 表格识别参数 TABLE_SETTINGS { vertical_strategy: lines, # 垂直线策略 horizontal_strategy: lines, # 水平线策略 snap_tolerance: 3, # 线条吸附容差 join_tolerance: 3, # 线段连接容差 }TABLE_SETTINGS 这几个参数是 pdfplumber 表格识别的关键。vertical_strategy 和 horizontal_strategy 默认是 lines意思是靠 PDF 里画的线条来识别表格边界。如果 PDF 表格没有画线就得改成 text 靠文字对齐来推断。snap_tolerance 和 join_tolerance 控制线条识别的宽容度数值越大越容易把断开的线连起来但也可能把不该连的连上。我一般从 3 开始试效果不好再调。3. 核心提取逻辑pdfplumber 怎么把表格抠出来3.1 打开 PDF 与页面遍历extractor.py 的第一个函数负责打开 PDF 并逐页提取表格。pdfplumber 的 API 设计很直观open 之后得到一个 PDF 对象pages 属性就是页面列表每页调用 extract_tables() 就能拿到表格数据。# extractor.py import pdfplumber from config import TABLE_SETTINGS, PAGES def extract_tables_from_pdf(pdf_path): 从单个 PDF 文件中提取所有表格 返回: list of list of list最外层是页中间是表最内层是行 all_tables [] with pdfplumber.open(pdf_path) as pdf: # 确定要处理的页面范围 if PAGES all: pages_to_process pdf.pages else: pages_to_process [pdf.pages[i] for i in PAGES] for page in pages_to_process: # 调用 extract_tables传入表格识别参数 tables page.extract_tables(table_settingsTABLE_SETTINGS) if tables: all_tables.extend(tables) return all_tables这段代码的逻辑很直接打开 PDF根据配置决定处理哪些页对每一页调用 extract_tables。返回的数据结构是三层嵌套——最外层是页面维度中间是表格维度一页可能有多个表最内层是行和单元格。extract_tables 返回的每个表格是一个二维列表每一行是一个列表每个单元格是字符串或 None。参数说明table_settings 传的就是 config 里定义的字典控制表格边界识别策略。如果不传pdfplumber 会用默认的 lines 策略。PAGES 参数支持 all 或页码列表页码从 0 开始计数。3.2 表格数据的清洗与规整extract_tables 拿到的原始数据有个常见问题单元格里可能有换行符、多余空格None 值需要转成空字符串还有些行可能全是空的需要剔除。这些清洗工作我放在 exporter.py 里做但提取阶段可以先做一轮基础处理。# extractor.py 续 def clean_table(raw_table): 对单个表格的原始数据进行基础清洗 - None 转空字符串 - 去除单元格内换行和首尾空格 - 剔除全空行 cleaned [] for row in raw_table: # 清洗每个单元格 new_row [] for cell in row: if cell is None: new_row.append() else: # 把单元格内的换行替换成空格去掉首尾空白 new_row.append(str(cell).replace(\n, ).strip()) # 如果整行都是空字符串跳过 if any(new_row): cleaned.append(new_row) return cleaned清洗逻辑里有个细节值得说单元格内的换行符替换成空格而不是直接删掉是因为有些表格里一个单元格存了多行地址信息直接删掉换行会导致文字粘连。剔除全空行的判断用 any(new_row)只要有一个单元格有内容就保留避免误删只有部分列有值的行。3.3 多表格合并与表头处理一个 PDF 里可能有多个表格或者多个 PDF 需要合并到一个 Excel。这时候表头处理就成了关键。我的做法是第一个表格保留表头后续表格如果表头行和第一个表格一致就跳过表头行只取数据行。# extractor.py 续 def merge_tables(tables, header_rows1): 合并多个表格只保留第一个表格的表头 tables: 多个表格的列表每个表格是二维列表 header_rows: 表头占几行 if not tables: return [], [] # 第一个表格的表头 header tables[0][:header_rows] merged_data [] for i, table in enumerate(tables): if i 0: # 第一个表格表头之后的数据全要 merged_data.extend(table[header_rows:]) else: # 后续表格判断表头是否重复 current_header table[:header_rows] if current_header header: # 表头相同跳过表头行 merged_data.extend(table[header_rows:]) else: # 表头不同保留全部可能是不同的表 merged_data.extend(table) return header, merged_data这个函数的判断逻辑是如果后续表格的表头和第一个表格完全一致说明是同一张表的延续跳过表头如果不一致说明是另一张结构不同的表全部保留。实际场景里同一份 PDF 里不同页的表格表头通常是一样的所以这个判断能过滤掉大量重复表头行。注意表头比较用的是列表相等判断要求单元格内容和顺序完全一致。如果 PDF 里表头有细微差异比如多了个空格会判断为不同表头。这种情况可以在比较前先做一轮清洗。4. 数据导出与批量处理从 DataFrame 到 Excel4.1 用 pandas 构建 DataFrame提取出来的二维列表要转成 Excel中间用 pandas 的 DataFrame 做过渡是最顺手的。exporter.py 的核心任务就是把 header 和 data 组装成 DataFrame然后写文件。# exporter.py import pandas as pd import os from config import OUTPUT_DIR, OUTPUT_FILENAME def export_to_excel(header, data, output_pathNone): 把表头和数据导出为 Excel header: 表头二维列表如 [[姓名, 金额, 日期]] data: 数据行二维列表 if not output_path: output_path os.path.join(OUTPUT_DIR, OUTPUT_FILENAME) # 确保输出目录存在 os.makedirs(os.path.dirname(output_path), exist_okTrue) # 构建 DataFrame if header: # 如果表头只有一行直接用作列名 if len(header) 1: df pd.DataFrame(data, columnsheader[0]) else: # 多行表头合并成单行 merged_header [_.join(col).strip() for col in zip(*header)] df pd.DataFrame(data, columnsmerged_header) else: # 没有表头用默认列名 df pd.DataFrame(data) # 写入 Excel df.to_excel(output_path, indexFalse, engineopenpyxl) print(f已导出 {len(df)} 行数据到 {output_path}) return output_path多行表头的处理是个容易忽略的点。有些复杂报表表头占两三行比如第一行是销售数据第二行是姓名、金额、日期。直接用第一行当列名会丢失信息我的做法是把多行表头按列拼接用下划线连接变成销售数据_姓名这样的列名。虽然不够优雅但至少信息不丢。4.2 批量遍历目录与异常处理main.py 是入口负责遍历 input_pdfs 目录对每个 PDF 调用提取和导出逻辑。批量处理最怕的是某个文件报错导致整个任务中断所以异常处理必须做。# main.py import os import traceback from config import INPUT_DIR, OUTPUT_DIR, HEADER_ROWS, MERGE_ALL from extractor import extract_tables_from_pdf, clean_table, merge_tables from exporter import export_to_excel def process_single_pdf(pdf_path): 处理单个 PDF返回清洗后的表格列表 raw_tables extract_tables_from_pdf(pdf_path) cleaned_tables [clean_table(t) for t in raw_tables] return cleaned_tables def main(): pdf_files [f for f in os.listdir(INPUT_DIR) if f.lower().endswith(.pdf)] if not pdf_files: print(f在 {INPUT_DIR} 中没有找到 PDF 文件) return print(f找到 {len(pdf_files)} 个 PDF 文件开始处理...) all_tables [] failed_files [] for filename in pdf_files: pdf_path os.path.join(INPUT_DIR, filename) try: tables process_single_pdf(pdf_path) if tables: all_tables.extend(tables) print(f[成功] {filename} - 提取到 {len(tables)} 个表格) else: print(f[跳过] {filename} - 未检测到表格) except Exception as e: failed_files.append((filename, str(e))) print(f[失败] {filename} - {e}) traceback.print_exc() # 合并并导出 if all_tables: if MERGE_ALL: header, data merge_tables(all_tables, HEADER_ROWS) export_to_excel(header, data) else: # 每个 PDF 单独导出 for i, table in enumerate(all_tables): header table[:HEADER_ROWS] data table[HEADER_ROWS:] export_to_excel(header, data, os.path.join(OUTPUT_DIR, ftable_{i}.xlsx)) # 输出处理报告 if failed_files: print(f\n处理完成{len(failed_files)} 个文件失败) for name, err in failed_files: print(f - {name}: {err}) else: print(\n全部处理完成无失败文件) if __name__ __main__: main()这段代码有几个设计点值得说。第一失败文件单独记录不影响其他文件继续处理最后统一报告。第二MERGE_ALL 控制是合并输出还是分文件输出合并适合结构一致的批量报表分文件适合每个 PDF 表格结构不同的场景。第三traceback.print_exc() 在调试阶段很有用能看到完整的调用栈定位问题快很多。4.3 输出格式的细节控制to_excel 默认用 openpyxl 引擎写出来的 Excel 列宽是固定的长文本会显示不全。如果对输出格式有要求可以在导出后调整列宽。# exporter.py 续 from openpyxl import load_workbook from openpyxl.utils import get_column_letter def auto_adjust_column_width(output_path): 自动调整 Excel 列宽让内容显示完整 wb load_workbook(output_path) ws wb.active for column_cells in ws.columns: max_length 0 column_letter get_column_letter(column_cells[0].column) for cell in column_cells: if cell.value: # 中文字符按2个宽度算 cell_len sum(2 if ord(c) 127 else 1 for c in str(cell.value)) max_length max(max_length, cell_len) # 设置列宽加2个字符的余量 ws.column_dimensions[column_letter].width min(max_length 2, 50) wb.save(output_path)列宽计算里有个细节中文字符在 Excel 里占的宽度大约是英文字符的两倍所以用 ord(c) 127 来判断并加权。上限设 50 是为了避免某一列内容特别长导致整表列宽失控。5. 避坑与常见问题排查5.1 表格识别不出来或识别错位现象extract_tables 返回空列表或者返回的表格行列错乱、单元格内容串位。原因最常见的是 PDF 表格没有画线pdfplumber 默认的 lines 策略找不到表格边界。其次是 PDF 里的线条是断开的或者表格线颜色太浅被忽略。解决把 TABLE_SETTINGS 里的 vertical_strategy 和 horizontal_strategy 改成 text让 pdfplumber 靠文字对齐来推断表格结构。如果还是不行调大 snap_tolerance 和 join_tolerance 到 5 或 8让断线更容易被连起来。再不行就得考虑用 camelot 或者 tabula 这类专门做表格提取的库了。5.2 单元格内容出现换行导致行数膨胀现象提取出来的数据行数比 PDF 里看到的行数多有些行只有一两个单元格有值。原因PDF 里一个单元格内如果有换行比如地址分两行写extract_tables 可能会把它拆成两行导致数据结构错乱。解决在 clean_table 里做合并处理。判断逻辑是如果某一行只有第一个单元格有值其余都是空且上一行的最后一个单元格有值就把这一行合并到上一行。这个逻辑需要根据实际 PDF 的格式来调整没有通用方案。5.3 多页表格表头重复导致数据冗余现象导出的 Excel 里每隔几十行就出现一行表头。原因PDF 每页都有表头extract_tables 把每页的表头都当数据提取了。解决用 merge_tables 函数里的表头比较逻辑把后续表格中与第一个表格表头相同的行跳过。如果表头有细微差异导致比较失败可以在比较前先做一轮 strip 和空格归一化。5.4 大文件处理内存溢出现象处理几百页的 PDF 时程序卡死或报 MemoryError。原因pdfplumber 会把整个 PDF 加载到内存页数太多时内存扛不住。解决分批处理每次只打开 PDF 的一部分页面。pdfplumber 支持 pdf.pages[0:50] 这样的切片处理完一批释放掉再处理下一批。另外extract_tables 的结果不要全部攒在内存里可以边提取边写入 Excel。5.5 输出 Excel 中文乱码或列宽异常现象Excel 打开后中文显示为乱码或者列宽太窄内容显示不全。原因乱码通常是编码问题但 pandas 的 to_excel 默认用 UTF-8一般不会乱码。更常见的是列宽问题openpyxl 默认列宽是固定的。解决用 auto_adjust_column_width 函数调整列宽。如果确实遇到乱码检查一下 PDF 本身的字体编码有些 PDF 用了非标准编码提取出来的就是乱码这种情况需要换提取引擎。6. 二次开发与改造思路从能跑到好用这个项目跑通之后下一步就是根据实际需求做改造。我拿它做过几个不同场景的适配下面说几个最实用的改造方向。第一个方向是加 OCR 支持。文本型 PDF 用 pdfplumber 没问题但扫描件就抓瞎了。改造思路是在 extract_tables_from_pdf 里加一个判断如果 extract_tables 返回空就把页面转成图片用 pytesseract 或 paddleocr 做文字识别再用表格结构还原算法把文字按位置组装成表格。这个改造工作量不小但覆盖场景会广很多。第二个方向是加 Web 界面。用 Flask 或 FastAPI 包一层上传 PDF、选择参数、下载 Excel非技术用户也能用。核心代码不用动只是把 main.py 里的函数暴露成 API 接口。我一般会用 FastAPI因为自带文档页面调试方便。第三个方向是加数据校验。提取出来的数据直接给业务用有风险可以加一层校验规则比如金额列必须是数字、日期列必须符合日期格式、必填列不能为空。校验不通过的行标红或者单独输出到另一个 sheet方便人工复核。第四个方向是支持更多输出格式。除了 Excel还可以输出 CSV、JSON、甚至直接写入数据库。pandas 的 DataFrame 支持 to_csv、to_json、to_sql改起来很快。改造的时候有个习惯我一直保持每加一个新功能先在 config.py 里加对应的开关默认关闭。这样老用户升级上来不会因为行为变化导致原有流程跑不通。比如加 OCR 支持时加一个 USE_OCR False 的配置需要的人自己打开。还有一个技巧是加日志。print 在调试阶段够用但生产环境还是得用 logging 模块把处理进度、失败原因、耗时统计都记下来。我一般会在 main.py 里配一个简单的 logging输出到文件和控制台出问题时翻日志比翻屏幕输出快得多。从那以后我每次接 PDF 表格提取的需求都强制先跑一遍小样本测试——拿三五份有代表性的 PDF 先跑通确认提取效果和数据结构符合预期再上批量。这个习惯帮我省了很多返工的时间。希望这些经验帮到你。本文还有配套的精品资源点击获取