ARTICLE · 1202640
PDF 里的表格复制出来全是乱的?用 Python 提取,先搞清楚你面对的是哪类
import pdfplumberwith pdfplumber.open("报表.pdf") as pdf:for page in pdf.pages:print(page.extract_tables())
然后终端要么输出 [],要么给你一堆错位的字符串,合并单元格变成了 "姓名张三",数字和单位黏在一起,七列的数据硬生生挤成了四列。
问题通常不在代码,而在一个被跳过的前提:PDF 里的“表格”,从来就不是表格。 它只是文字、线条和坐标的集合。你看到的“行列对齐”,在 PDF 内部可能是一堆散落的字符对象,靠位置“看起来像表格”而已。
所以提取 PDF 表格的第一件事,不是选库,而是判断你手里的 PDF 是哪一类。
先花 30 秒,回答三个问题
在写任何代码之前,先做一次快速体检。这三问决定了后续路径:
第一问:能选中文字吗? 用鼠标在 PDF 里拖选,能选中、能复制,说明有文本层,提取工具才有用武之地。如果怎么选都选不中,或者复制出来是乱码,那大概率是扫描件——所有基于文本层的库(pdfplumber、Camelot、Tabula)对它都无效,必须先上 OCR。
第二问:表格有边框线吗? 放大看单元格之间有没有明确的横竖线条。有线条的表格,对识别工具最友好;没有线条、纯靠空白分隔的“无线表格”,难度立刻上一个台阶。
第三问:布局规整吗? 是一张老老实实从头排到尾的表格,还是页面分两栏、表格嵌在文字中间、旁边还有脚注和浮动文本框?布局越乱,越需要手动指定提取区域或调参数。
这三个问题没有标准答案,但会直接把你导向不同的工具。下面这张表可以作为快速决策的参考:
| 你的 PDF 情况 | 首选工具 | 原因 |
|---|---|---|
| 有文本层,有明显边框线 | Camelot(flavor="lattice") | 靠线条定位,准确率最高 |
| 有文本层,无边框、靠空白对齐 | pdfplumber(text 策略)或 Tabula(stream=True) | 从文字位置推断行列 |
| 布局复杂、需要精确控制区域 | pdfplumber | 可以按坐标裁剪、可视化调试 |
| 扫描件 / 图片型 | OCR 预处理(Tesseract + pdf2image) | 没有文本层,文本提取库全部失效 |
主力路径一:pdfplumber,把“控制权”拿到手里
pdfplumber 的定位不是“一键提取表格”,而是给你访问 PDF 内部几何信息的权限——每一个字符、每一条线段、每一个矩形,你都能拿到它们的坐标。这意味着它更灵活,但也意味着你需要知道怎么“指挥”它。
基础用法:先看它默认认出了什么
import pdfplumberwith pdfplumber.open("example.pdf") as pdf:page = pdf.pages[0]tables = page.extract_tables()for table in tables:for row in table:print(row)
extract_tables() 返回一个列表的列表:外层是页面上的多个表格,内层是行,行里是单元格的字符串或 None。默认策略会优先找线条来划分表格边界。
如果输出是 [],不代表没有表格,而是默认策略没认出来。这时候需要换策略。
策略切换:有线条用 lines,无线条用 text
pdfplumber 的 table_settings 里有两个关键参数:vertical_strategy 和 horizontal_strategy。对于有明确边框线的表格,保持默认的 "lines" 即可;对于没有边框、靠文字对齐的表格,改成 "text":
settings = {"vertical_strategy": "text","horizontal_strategy": "text","text_x_tolerance": 5,"text_y_tolerance": 3,}tables = page.extract_tables(table_settings=settings)
text_x_tolerance 和 text_y_tolerance 控制的是“多近的文字算同一列 / 同一行”。值越小,划分越严格;值越大,越容易把本应分开的内容合并到一起。
终极调试工具:debug_tablefinder()
pdfplumber 最值得单独记住的功能,是可视化调试。当你不确定它到底“看到”了什么,直接画出来:
im = page.to_image(resolution=150)im.debug_tablefinder(table_settings=settings)im.save("debug.png")
打开生成的图片,你会看到 pdfplumber 识别出的表格边界、线条和交点。如果它画出的框和真实表格对不上,问题出在策略或容差参数上,而不是代码写错了。这个功能把“盲猜参数”变成“看着图调”,能省掉大量试错时间。
跨页表格:表头去重是核心
财务报表、长清单经常跨页,每一页的表格顶部都会重复表头。最简单的处理逻辑是:第一页提取表头和数据,后续页只取数据,且跳过与第一页相同的表头行:
all_rows = []headers = Nonewith pdfplumber.open("report.pdf") as pdf:for page_num, page in enumerate(pdf.pages):tables = page.extract_tables()if not tables:continuetable = tables[0]if page_num == 0:headers = table[0]all_rows.extend(table[1:])else:if table[0] == headers:all_rows.extend(table[1:])else:all_rows.extend(table)result = [headers] + all_rows if headers else all_rows
这个逻辑假设每一页的表格结构一致。如果表头本身跨了两三行,需要先把表头行拼成完整列名,再复用同一套去重逻辑。
主力路径二:Camelot,有边框线时先试它
Camelot 的设计思路比 pdfplumber 更“上层”:你告诉它用哪种解析方式,它去处理底层的线条和文字。它的 lattice 模式在有明显单元格边框的 PDF 上表现非常稳定,因为它是用计算机视觉方法去检测 PDF 中画出来的线条。
lattice 与 stream 的选择
import camelot# 有边框线:latticetables = camelot.read_pdf("invoice.pdf", flavor="lattice")# 无边框线:streamtables = camelot.read_pdf("statement.pdf", flavor="stream")
lattice 是默认模式,适合“格子画得清清楚楚”的表格。stream 则靠空白间距推断列边界,适合无线表格,但参数更敏感。
先 plot,再提取
Camelot 同样提供了可视化调试,而且比 pdfplumber 更“一眼看懂”:
camelot.plot(tables[0], kind="contour").show()contour 图会显示 Camelot 检测到的表格区域和文字块轮廓。如果表格区域没框对,可以手动用 table_areas 指定坐标:tables = camelot.read_pdf("file.pdf",flavor="lattice",table_areas=["100,700,500,100"], # x1,y1,x2,y2,y轴向下为正)
对于无线表格,edge_tol 控制文字边缘的容忍度,row_tol 控制行间距的判定。调整这些参数的目标只有一个:让 Camelot 画出的框和肉眼看到的表格边界重合。
Camelot 的一个实际限制
Camelot 的 lattice 模式依赖 PDF 中真实存在的线条对象。有些 PDF 看起来有线,但那些“线”其实是图片的一部分或者背景色块,不是矢量线段。这种 PDF 用 lattice 会返回 “No tables found”。遇到这种情况,换 stream,或者换 pdfplumber 的 text 策略,往往比继续调 Camelot 参数更有效。
提取出来只是“字符串”,清洗才是拿到数据
无论用哪个库,提取结果都是原始字符串,不做类型推断,不做格式清洗。"¥1,234.56" 不会自动变成 1234.56,"2025年12月" 也不会自己变成日期。
一个务实的清洗顺序是:先修表头,再填合并单元格,最后处理数值和日期。
表头问题是最常见的坑。多级表头或跨行表头会被库当成数据行,导致 df.columns 是一堆无意义的默认名。可以手动把前 1 到 3 行拼成列名:
import pandas as pd# 假设 table 是 pdfplumber 返回的原始列表df = pd.DataFrame(table[2:], columns=["列1", "列2", "列3"]) # 跳过表头行,手动命名
如果表头有合并单元格,先探测表头区域,把多行拼接成单层列名,再重新赋值给 df.columns。
纵向合并的单元格在提取后会表现为 None 或空字符串。对于“省份”“部门”这类跨行合并的列,用前向填充:
df["省份"] = df["省份"].fillna(method="ffill")但要注意边界:如果该列开头就是空的,前向填充会留下空值,需要单独处理。
数值列的清洗可以用 pandas 的字符串替换一次性去掉货币符号、千分位逗号和空格:
df["金额"] = (df["金额"].astype(str).str.replace(r"[¥$,\s]", "", regex=True).replace("", None).astype(float))
注意 pandas 2.0 以上版本中 regex=True 需要显式写出,不能省略。
如果上面的库都返回空:你大概率面对的是扫描件
pdfplumber、Camelot、Tabula 有一个共同的硬边界:它们读的是 PDF 的文本层,扫描件没有文本层。如果快速体检时复制出来是乱码或空,代码写得再漂亮也没用。
扫描件的处理路径完全不同:先把 PDF 页面转成图片,再用 OCR 识别文字,然后从识别结果中重建表格。一个最小化的流程是:
from pdf2image import convert_from_pathimport pytesseractpages = convert_from_path("scanned.pdf", dpi=300)for page_img in pages:text = pytesseract.image_to_string(page_img, lang="chi_sim+eng")# 后续需要从 text 中解析表格结构
但需要清醒认识到:OCR 得到的是文本流,行列结构需要额外重建。简单的表格可以靠坐标或分隔符解析,复杂表格通常需要 img2table 这类专门针对图片表格的库,或者接入更强的表格结构识别模型。这条路径的复杂度和纯文本 PDF 不是一个量级,如果扫描件是少数,人工核对可能比搭 OCR 流水线更划算。
最后:没有“最好”的库,只有“先判断、再动手”
回到开头的三个问题。你的 PDF 有没有文本层、有没有边框线、布局乱不乱,决定了你应该从哪个工具开始。
一个有边框的文本 PDF,Camelot lattice 可能三行代码就出结果;一个无边框的银行流水,pdfplumber 的 text 策略配上 debug_tablefinder() 看着图调参数,比反复猜 edge_tol 高效得多;一个扫描件,任何文本提取库都救不了,直接走 OCR。
真正重要的不是背下每个库的参数,而是养成一个习惯:打开 PDF,先做一次 30 秒的分类,再写第一行代码。 分类对了,剩下的只是调参和清洗;分类错了,再漂亮的代码也只会返回一个空列表。















































































