☰
Python批量提取PDF表格到Excel:pdfplumber与openpyxl实战
2026/10/18 21:06:01 网站建设 项目流程

简介:这份资源面向需要批量处理文档数据的Python学习者与办公自动化从业者,聚焦于将PDF中的表格数据一键提取并导出为Excel这一常见痛点。包内共14个文件,以png截图、xlsx结果文件、pdf原始文档为主,另含1个ipynb交互式笔记与1个py脚本,压缩包约18.34MB,覆盖从原始年报PDF到提取后Excel的完整对照样例。内容围绕pdfplumber、pandas、openpyxl等库的实际调用展开,并附带东旭蓝天、华特气体、保利地产等真实年报表格作为练习素材,便于读者直接运行脚本、观察提取效果并对照截图排查问题。目前已有117人学习下载,适合希望掌握PDF表格抽取与数据清洗技巧、提升办公数据处理效率的初中级Python使用者参考。

1. 批量表格提取:从几十份 PDF 里把数据捞进 Excel

上个月帮一个做财务的朋友处理报销凭证,对方甩过来一个压缩包,里面塞了四十多份 PDF 对账单,每份都有三四个表格。他原本的计划是复制粘贴,干到第三份就崩溃了。我看了眼文件结构,表格边框规整、没有合并单元格,这种场景用 Python 做批量提取再合适不过。这个项目就是干这件事的:把 PDF 里的表格识别出来,按页或按文件写入 Excel,支持批量遍历目录。它适合经常跟报表、发票、对账单打交道的运营和财务人员,也适合想找一个 pdfplumber 实战入口的 Python 开发者。整个脚本不依赖付费接口,本地跑,数据不出机器。

2. 技术选型与目录结构:为什么是 pdfplumber 加 openpyxl

2.1 三个候选库的取舍

处理 PDF 表格,Python 圈子里常见的选择有三个:pdfplumber、camelot、tabula。tabula 依赖 Java 运行时,部署时多一层环境负担;camelot 对表格线敏感,遇到无边框表格容易翻车,而且底层也依赖 opencv 和 ghostscript。pdfplumber 纯 Python 实现,基于 pdfminer.six,安装即用,对有线表格的识别率相当稳,还能拿到每个字符的坐标,方便做二次判断。

这个项目选的就是 pdfplumber 负责解析,openpyxl 负责写 Excel。openpyxl 的优势是能精确控制单元格样式、合并、列宽,而且不依赖 Excel 软件本身。两者组合,一个管读一个管写,职责清晰。

提示:如果你的 PDF 是扫描件,pdfplumber 提取不到文字层,需要先走 OCR。这个项目不覆盖扫描件场景。

2.2 目录结构长什么样

一个能直接跑的工程,目录不用复杂,但得让人一眼看懂:

pdf_table_extractor/ ├── main.py # 入口,命令行参数解析 ├── extractor.py # PDF 表格提取核心逻辑 ├── writer.py # Excel 写入封装 ├── config.py # 可调参数集中管理 ├── requirements.txt # 依赖清单 ├── input/ # 待处理的 PDF 放这里 └── output/ # 生成的 Excel 落这里

把提取逻辑和写入逻辑拆开,是为了后面改需求时不动一处牵全身。比如你想把结果写成 CSV,只改 writer.py 就行;想换解析库,只动 extractor.py。config.py 里放表格识别参数、输出文件名规则、日志级别这些,避免硬编码散落各处。

2.3 依赖安装与环境确认

requirements.txt 里就三行:

pdfplumber==0.10.3 openpyxl==3.1.2 pandas==2.1.4

pandas 在这里不是必须的,但做数据清洗和拼接时非常顺手,建议留着。安装命令:

pip install -r requirements.txt

装完先验证一下 pdfplumber 能不能正常导入,有些环境里 pdfminer.six 的版本冲突会导致 import 报错:

import pdfplumber print(pdfplumber.__version__)

如果这一步报ModuleNotFoundError,大概率是虚拟环境没激活,或者 pip 装到了系统 Python 而不是当前环境。用which python和which pip确认两者指向同一路径。

3. 核心提取逻辑:从页面对象到二维列表

3.1 打开 PDF 与逐页遍历

pdfplumber 的用法很直观,open之后得到一个 PDF 对象,pages属性是页面列表。核心提取函数大概长这样:

import pdfplumber def extract_tables_from_pdf(pdf_path, table_settings=None): """ 从单个 PDF 中提取所有表格 :param pdf_path: PDF 文件路径 :param table_settings: 传给 extract_tables 的参数字典 :return: 列表,每个元素是 (页码, 表格二维列表) """ results = [] with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages, start=1): # extract_tables 返回该页所有表格,每个表格是二维列表 tables = page.extract_tables(table_settings or {}) for table in tables: if table and len(table) > 1: # 至少两行才算有效表格 results.append((page_num, table)) return results

这里有几个点值得说。extract_tables返回的是一个三维结构:页面上可能有多个表格,每个表格是一个二维列表,行是列表元素,单元格是字符串。空单元格返回None而不是空字符串,后面写入 Excel 时要处理。len(table) > 1这个判断是为了过滤掉那些被误识别成表格的单行文本块,实际跑的时候你会发现这种误判不少。

3.2 表格识别参数怎么调

table_settings是 pdfplumber 提供的调节入口,默认值对规整表格够用,但遇到特殊情况需要改。常用参数:

参数默认值作用
vertical_strategy"lines"垂直线识别策略,可选 lines/text
horizontal_strategy"lines"水平线识别策略
snap_tolerance3线条吸附容差,值越大越容易把邻近线合并
join_tolerance3线段连接容差
edge_min_length3最小边缘长度,过滤噪点

如果 PDF 表格没有边框线,把两个 strategy 都改成"text",pdfplumber 会根据文字对齐来推断列边界。代价是准确率下降,列多的时候容易串列。我一般会先跑一遍默认参数,看输出结果再决定要不要调。

# 无边框表格的配置示例 no_border_settings = { "vertical_strategy": "text", "horizontal_strategy": "text", "snap_tolerance": 5, "join_tolerance": 5, }

snap_tolerance调大到 5 是为了容忍扫描或排版造成的轻微偏移。这个值不是越大越好,超过 8 之后相邻两列可能被合并成一列,反而更糟。

3.3 数据清洗:None 值和换行符

提取出来的原始数据不能直接写 Excel,得先过一遍清洗。两个高频问题:单元格是None,以及单元格内有多余换行。

def clean_table(table): """清洗表格数据:None 转空串,去除换行和首尾空格""" cleaned = [] for row in table: new_row = [] for cell in row: if cell is None: new_row.append("") else: # PDF 里单元格内换行很常见,统一替换成空格 text = str(cell).replace("\n", " ").strip() new_row.append(text) cleaned.append(new_row) return cleaned

换行符处理是个血泪经验。PDF 表格里一个单元格的文字经常被硬换行拆成多行,直接写进 Excel 会变成单元格内换行,看起来乱且影响后续筛选。替换成空格之后,数据在 Excel 里是一整行,做透视表方便得多。当然如果你需要保留原始换行,把replace那步去掉即可。

4. 写入 Excel 与批量调度:让脚本能处理整个目录

4.1 openpyxl 写入与样式控制

写入逻辑封装在 writer.py 里,核心是创建一个 Workbook,逐行写入,顺便设置列宽和表头加粗:

from openpyxl import Workbook from openpyxl.styles import Font, Alignment def write_tables_to_excel(tables_data, output_path): """ 将提取的表格写入 Excel :param tables_data: [(页码, 表格二维列表), ...] :param output_path: 输出 xlsx 路径 """ wb = Workbook() wb.remove(wb.active) # 删掉默认空 sheet for idx, (page_num, table) in enumerate(tables_data, start=1): ws = wb.create_sheet(title=f"P{page_num}_T{idx}") for row_idx, row in enumerate(table, start=1): for col_idx, value in enumerate(row, start=1): cell = ws.cell(row=row_idx, column=col_idx, value=value) if row_idx == 1: cell.font = Font(bold=True) cell.alignment = Alignment(horizontal="center") # 根据内容长度粗略设置列宽 for col in ws.columns: max_len = max(len(str(c.value or "")) for c in col) ws.column_dimensions[col[0].column_letter].width = min(max_len + 4, 40) wb.save(output_path)

每个表格单独一个 sheet,命名带上页码和序号,方便回溯。表头加粗居中,列宽按内容自适应但封顶 40,避免某一列文字特别长把表格撑得没法看。wb.remove(wb.active)这步别漏,否则会多一个空白 sheet。

4.2 批量遍历目录与异常隔离

单个文件跑通之后,批量处理就是套一层目录遍历。关键是异常隔离:某个 PDF 损坏或加密,不能让整个批次挂掉。

import os from pathlib import Path def batch_process(input_dir, output_dir): """批量处理目录下所有 PDF""" input_path = Path(input_dir) output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) pdf_files = list(input_path.glob("*.pdf")) print(f"共发现 {len(pdf_files)} 个 PDF 文件") for pdf_file in pdf_files: try: tables = extract_tables_from_pdf(str(pdf_file)) if not tables: print(f"[跳过] {pdf_file.name} 未识别到表格") continue out_file = output_path / f"{pdf_file.stem}_tables.xlsx" write_tables_to_excel(tables, str(out_file)) print(f"[完成] {pdf_file.name} -> {out_file.name}") except Exception as e: # 单个文件失败不影响后续 print(f"[失败] {pdf_file.name}: {e}")

glob("*.pdf")只匹配当前目录,不递归子目录。如果你的 PDF 分散在多级目录里,换成rglob("*.pdf")。异常捕获这里故意捕获宽泛的Exception,因为 PDF 解析可能抛出各种底层异常,逐个列举不现实。打印出文件名和错误信息,事后可以针对性排查。

4.3 命令行入口与参数设计

main.py 用 argparse 接收参数,让脚本不用改代码就能指定输入输出目录:

import argparse from extractor import batch_process def main(): parser = argparse.ArgumentParser(description="批量提取 PDF 表格到 Excel") parser.add_argument("-i", "--input", default="input", help="PDF 所在目录") parser.add_argument("-o", "--output", default="output", help="Excel 输出目录") args = parser.parse_args() batch_process(args.input, args.output) if __name__ == "__main__": main()

运行方式:

python main.py -i ./input -o ./output

不传参数就用默认目录。这种设计对非开发者友好,把 PDF 丢进 input 文件夹,双击运行或者命令行敲一行就行。

5. 避坑与常见问题:那些跑起来才会遇到的事

5.1 提取结果全是空值

现象:脚本正常跑完,Excel 里每个单元格都是空的。原因通常是 PDF 是扫描件,没有文字层,pdfplumber 只能拿到图像。解决:用page.extract_text()测试一下,如果返回空字符串,说明确实是扫描件,需要先做 OCR 再提取。另一个可能是表格线颜色太浅,pdfplumber 默认阈值识别不到,把snap_tolerance调大试试。

5.2 列错位或串列

现象:提取出来的表格列数对不上,某一列的数据跑到了隔壁。原因多半是表格没有完整边框,或者存在跨页表格。解决:先确认表格是否有线,无线表格改用text策略;跨页表格需要手动拼接,pdfplumber 不会自动合并。我一般会在提取后打印每张表的列数,列数突变的地方就是问题所在。

5.3 中文乱码或方框

现象:Excel 里中文显示为乱码。原因:PDF 内嵌字体缺少 Unicode 映射,pdfplumber 提取出来的是乱码字符。解决:这种情况比较棘手,可以尝试用pdfplumber的chars属性拿到字符对象,检查text字段是否正常。如果源头就是乱码,换库也救不了,只能走 OCR。写入 Excel 时确保用 openpyxl 默认的 UTF-8 处理,不要手动指定编码。

5.4 内存占用过高

现象:处理大 PDF 时内存飙升甚至卡死。原因:pdfplumber 会把页面对象缓存在内存里,几百页的 PDF 加上复杂表格,占用很可观。解决:不要一次性把所有页面结果存列表再写,改成逐页提取、逐页写入,或者分批处理。另外with pdfplumber.open()的上下文管理器一定要用,确保文件句柄及时释放。

5.5 输出文件被占用

现象:第二次运行时保存 Excel 报PermissionError。原因:上一次生成的 Excel 还开着没关。解决:这是 Windows 下的经典问题,脚本层面可以在保存前检查文件是否存在并尝试删除,或者输出文件名带上时间戳避免覆盖。我习惯在输出文件名里加日期,既避免占用冲突,也方便追溯版本。

6. 二次开发:把提取结果接进你的数据管道

跑通之后,这个脚本最值钱的地方不是提取本身,而是它作为一个数据入口,能接进后续流程。我一般会从三个方向改。

第一个方向是输出结构化。现在每个表格一个 sheet,适合人工看,但不适合程序消费。如果你要把数据灌进数据库或者做分析,把 writer.py 改成输出 CSV 或者直接写 SQLite。pandas 在这里很好用:

import pandas as pd def tables_to_dataframe(tables_data): """把提取结果拼成一个 DataFrame,方便后续处理""" frames = [] for page_num, table in tables_data: df = pd.DataFrame(table[1:], columns=table[0]) # 首行做表头 df["_source_page"] = page_num frames.append(df) return pd.concat(frames, ignore_index=True)

加上_source_page列是为了追溯数据来源,出问题时能定位到具体页码。表头处理这里假设每个表格首行都是列名,如果实际数据不是这样,需要按业务调整。

第二个方向是加校验规则。财务对账单这类场景,金额列必须能转成数字,日期列必须符合格式。在写入前加一层校验,把不合格的行单独记到日志里:

def validate_row(row, numeric_cols): """校验指定列是否为数字,返回错误信息列表""" errors = [] for col in numeric_cols: try: float(str(row[col]).replace(",", "")) except (ValueError, KeyError): errors.append(f"列 {col} 值异常: {row.get(col)}") return errors

replace(",", "")是为了处理千分位分隔符,这个细节不注意的话,1,234.56会直接抛异常。校验不过的行不要静默丢弃,记下来人工复核,数据处理的后悔药就是日志。

第三个方向是加进度和重试。批量处理几百个文件时,你需要知道跑到哪了。用 tqdm 加个进度条,失败的文件记到一个 failed.txt 里,下次只跑失败的:

from tqdm import tqdm for pdf_file in tqdm(pdf_files, desc="提取进度"): ...

重试逻辑针对的是那些因为文件被占用或临时读取失败的情况,隔几秒重试一次往往就好了。但如果是 PDF 本身损坏,重试多少次都没用,所以重试次数设 2 到 3 次就够,别死循环。

改造的时候有个原则:提取逻辑和业务逻辑分开。extractor.py 只管从 PDF 拿数据,校验、转换、入库这些放到单独的模块。这样换业务场景时,提取部分不用动。我见过太多项目把校验规则硬塞进提取函数里,后来想复用到另一个报表就完全没法拆。

从那以后我每次做这类批量提取,都强制先拿三个样本文件跑通全流程,确认输出格式和边界情况,再放开跑全量。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询