Python PDF 表格提取
从 PDF 中提取表格是文档处理流水线里最常见的任务之一。不论是从年报里抠出财务数据、抓取商品目录,还是把结构化数据喂给 LLM,可靠的表格提取都是必不可少的一环。本指南完整梳理了在 Python 中提取 PDF 表格所需的一切,从一行代码的快速写法,到面向生产的跨页表格处理流程。
检测引擎
PDF Oxide 采用通用的表格检测流水线 边 → 对齐/合并 → 交点 → 单元格 → 分组,与 Tabula、pdfplumber、PyMuPDF 的思路一致,但完全用纯 Rust 实现。
检测能力:
- 基于交点 — 寻找水平与垂直线的交叉,再用四角矩形拼出单元格,通过 union-find 汇聚成表格。
- 扩展网格 — 水平线与垂直线位于页面不同区域时,根据全部坐标的笛卡儿积构建虚拟网格。
- 带列感知的文本检测 — 通过 X 投影直方图拆分双栏版式,再按列分别跑纯文本的表格检测。
- 以水平分隔线为边界的文本表格 — 识别仅由水平线限定、没有垂直线的表格(学术论文里很常见)。
- 混合行检测 — 只有垂直边框时,根据文本的 Y 位置推断行分界(例如发票明细)。
- 点线与虚线的重构 — 把短线段拼接成连续的边。
- 分节分隔线切分 — 在整页宽度的水平分隔线处,把多区段的表单切开。
- 边覆盖过滤 — 剔除不参与任何候选网格的孤立边。
配置
TableDetectionConfig 暴露了以下可调参数:
| 字段 | 默认值 | 说明 |
|---|---|---|
horizontal_strategy |
"lines_strict" |
"lines_strict"、"lines"、"text" 或 "explicit" |
vertical_strategy |
"lines_strict" |
同一组取值 |
v_split_gap |
20.0 pt |
触发拆分为独立表格的垂直线间距(v0.3.20 之前固定为 4 pt) |
snap_tolerance |
3.0 pt |
合并邻近边的容差 |
text_tolerance |
3.0 pt |
合并文本行的容差 |
行为变更
从 v0.3.20 起,Python 的 extract_tables() 默认策略改为 Both(同时按线条与文本检测)。依赖旧版纯文本默认值的页面需要显式传入 horizontal_strategy="text" 和 vertical_strategy="text"。
Python 绑定现在会正确读取 table_settings 字典里的 vertical_strategy — 此前这一值会被静默忽略。
渲染
提取出的表格以空格填充的列对齐方式输出,替换掉早期版本里的 ASCII 字符框。货币列与数字列会自动右对齐。表单编号前缀("1 Apr 11" → "Apr 11")和装饰性的短横线/下划线单元格("------")都会在渲染阶段清除。
使用 Markdown 转换从 PDF 中提取表格数据:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("invoice.pdf")
md = doc.to_markdown(0, detect_headings=True)
print(md)
# 输出中包含 GFM 格式的表格:
# | Item | Qty | Price |
# |------|-----|-------|
# | Widget | 10 | $9.99 |
WASM
import { WasmPdfDocument } from "pdf-oxide-wasm";
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdown(0);
console.log(md);
// 输出中包含 GFM 格式的表格:
// | Item | Qty | Price |
// |------|-----|-------|
// | Widget | 10 | $9.99 |
doc.free();
Rust
use pdf_oxide::PdfDocument;
let mut doc = PdfDocument::open("invoice.pdf")?;
let md = doc.to_markdown(0, true)?;
println!("{}", md);
Go
package main
import (
"fmt"
"log"
pdfoxide "github.com/yfedoseev/pdf_oxide/go"
)
func main() {
doc, err := pdfoxide.Open("invoice.pdf")
if err != nil { log.Fatal(err) }
defer doc.Close()
md, err := doc.ToMarkdown(0)
if err != nil { log.Fatal(err) }
fmt.Println(md)
}
C#
using PdfOxide;
using var doc = PdfDocument.Open("invoice.pdf");
Console.WriteLine(doc.ToMarkdown(0));
PDF Oxide 通过对齐文本块的空间分析识别表格版式,并以 GitHub Flavored Markdown 输出。
为什么从 PDF 提取表格这么难
只要把 PDF 里的表格复制粘贴到电子表格里一次,就能体会到结果通常是一团乱麻。这并不是 PDF 阅读器的 bug,而是 PDF 格式本身的基本限制。
PDF 没有"表格"这个概念。 HTML 用 <table>、<tr>、<td> 定义表格结构,PDF 却只存绘图指令:在坐标 (x, y) 放一个字形,从 A 到 B 画一条线。没有任何语义层告诉你"这些字符属于第 3 行第 2 列的单元格"。每一个表格提取库都必须通过分析文本和线条在页面上的空间位置,把这层结构重新还原出来。
这种还原之所以困难,原因有不少:
-
有边框表格与无边框表格。 能看到网格线时,提取工具可以直接把线当作单元格边界。而无边框表格(财报、政府报告、学术论文里很常见)根本没有线,库只能凭借文本块之间的空白去推断列边界;列宽不固定或者数字靠右对齐时,出错的概率很高。
-
合并单元格与跨列表头。 跨三列的表头在外观上只是一个较宽的文本块。没有网格线作分界,解析器很难可靠地判断表头覆盖哪些列。有些库处理得不错,很多库会悄悄给出错乱的结果。
-
单元格里的多行内容。 单元格里有一段会自动换行的文字时,按行简单处理的解析器会把每一行当作独立的表格行。要把它们重新归并回一个单元格,必须理解每一行的垂直范围。
-
跨页表格。 大型表格经常横跨两页甚至更多。表头行可能在每页重复,也可能只在首页出现,行与行之间还可能夹着页眉页脚、水印、页码。把这些碎片拼回一个完整表格,需要懂得分页逻辑。
-
旋转文本和非常规版式。 有些 PDF 会把列标题旋转显示,或者把表格放进多栏版式。这些特殊情况会打破大多数解析器"从左到右、从上到下"的阅读顺序假设。
了解这些难点有助于给手头的文档挑合适的工具。对于整齐对齐的表格——大多数发票、订单确认、简单报告——像 PDF Oxide 这样快速的空间分析方案就够用了。对于含有复杂合并、无边框版式或特殊排版的文档,可能需要更丰富启发式策略的库。
表格提取:PDF Oxide 与其他库对比
在 Python 中挑选 PDF 表格提取库,要结合文档类型、性能要求和输出格式。主要选项的对比如下:
| 库 | 表格检测 | 有边框表格 | 无边框表格 | 输出格式 | 速度 |
|---|---|---|---|---|---|
| PDF Oxide | 内置 | 是 | 基础 | Markdown/HTML | 0.8 ms |
| pdfplumber | 内置 | 是 | 高级 | Python 列表 | 23.2 ms |
| Camelot | 内置 | 是 | 是(lattice/stream) | DataFrame | ~50 ms+ |
| PyMuPDF | 基础(v1.23+) | 是 | 有限 | DataFrame | 4.6 ms |
| pypdf | 无 | 无 | 无 | 无 | 无 |
| tabula-py | 内置 | 是 | 是 | DataFrame | ~100 ms+(Java) |
PDF Oxide 是遥遥领先的最快选项。通过对齐文本块的空间分析识别表格,直接产出干净的 GitHub Flavored Markdown。平均提取耗时 0.8 ms,比 pdfplumber 快 29 倍,比 tabula-py 快 100 倍以上。对有边框表格和简单对齐的无边框表格处理得很好。在反正需要 Markdown 的 LLM 流水线里,是最自然的选择。
pdfplumber 在无边框表格检测方面最成熟。find_tables() 方法提供了基于文本对齐的可配置策略来识别行与列,对合并单元格和多行内容的支持也优于多数替代方案。代价是速度:23.2 ms/页会让批处理明显变慢。
Camelot 提供两种检测模式 —— lattice(有边框)和 stream(无边框)。它直接给出 pandas DataFrame,对数据分析流程很方便。缺点是依赖 Ghostscript 和 OpenCV,安装负担较重,而且在纯 Python 选项中速度最慢。
PyMuPDF (fitz) 在 1.23 版本新增了基础的表格提取。速度快(4.6 ms),处理简单的有边框表格不错,但无边框表格的支持不如 pdfplumber 或 Camelot。
pypdf 不具备任何表格检测能力。它只输出原始文本,要重建表格结构得自己写解析器。
tabula-py 是 Java 版 Tabula 的 Python 封装。对有边框与无边框表格都有不错的检测效果,但需要 Java 运行时,而且因为 JVM 启动开销,是最慢的选项。更适合一次性的提取任务,不太合适高吞吐的流水线。
在绝大多数生产场景下,推荐先以 PDF Oxide 作为主提取器以获得速度与简洁,再对极少数需要高级启发式的复杂表格文档回退到 pdfplumber。
安装
pip install pdf_oxide
基础表格提取
作为 Markdown 表格
最简单的做法 —— 把页面转换成 Markdown,里面的表格本身就是 GFM 语法:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("report.pdf")
for i in range(doc.page_count()):
md = doc.to_markdown(i, detect_headings=True)
if "|" in md: # 该页包含表格
print(f"--- 第 {i + 1} 页 ---")
print(md)
WASM
const doc = new WasmPdfDocument(bytes);
for (let i = 0; i < doc.pageCount(); i++) {
const md = doc.toMarkdown(i);
if (md.includes("|")) { // 该页包含表格
console.log(`--- 第 ${i + 1} 页 ---`);
console.log(md);
}
}
doc.free();
Rust
let mut doc = PdfDocument::open("report.pdf")?;
for i in 0..doc.page_count()? {
let md = doc.to_markdown(i, true)?;
if md.contains("|") {
println!("--- 第 {} 页 ---", i + 1);
println!("{}", md);
}
}
Go
doc, _ := pdfoxide.Open("report.pdf")
defer doc.Close()
n, _ := doc.PageCount()
for i := 0; i < n; i++ {
md, _ := doc.ToMarkdown(i)
if strings.Contains(md, "|") {
fmt.Printf("--- 第 %d 页 ---\n%s\n", i+1, md)
}
}
C#
using var doc = PdfDocument.Open("report.pdf");
for (int i = 0; i < doc.PageCount; i++)
{
var md = doc.ToMarkdown(i);
if (md.Contains("|"))
Console.WriteLine($"--- 第 {i + 1} 页 ---\n{md}");
}
结构化表格提取(v0.3.34)
如果希望以类型化方式访问行和边界框,而不必去解析 Markdown,可以调用 ExtractTables(pageIndex)(Go、C#)或 extract_tables(page)(Python、Rust)。每个表格会暴露结构化的单元格,结果可以不经正则直接导入数据库或 DataFrame。
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("invoice.pdf")
for table in doc.extract_tables(0):
for row in table.rows:
print(row)
Rust
let mut doc = PdfDocument::open("invoice.pdf")?;
for table in doc.extract_tables(0)? {
for row in &table.rows {
println!("{:?}", row);
}
}
Go
doc, _ := pdfoxide.Open("invoice.pdf")
defer doc.Close()
tables, _ := doc.ExtractTables(0)
for _, t := range tables {
for _, row := range t.Rows {
fmt.Println(row)
}
}
C#
using var doc = PdfDocument.Open("invoice.pdf");
foreach (var table in doc.ExtractTables(0))
foreach (var row in table.Rows)
Console.WriteLine(string.Join(" | ", row));
把 Markdown 表格解析为行
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("invoice.pdf")
md = doc.to_markdown(0)
# 从 Markdown 中提取表格行
rows = []
for line in md.split("\n"):
line = line.strip()
if line.startswith("|") and not line.startswith("|--"):
cells = [cell.strip() for cell in line.split("|")[1:-1]]
rows.append(cells)
header = rows[0] if rows else []
data = rows[1:] if len(rows) > 1 else []
print(f"列: {header}")
for row in data:
print(row)
WASM
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdown(0);
const rows = [];
for (const line of md.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("|") && !trimmed.startsWith("|--")) {
const cells = trimmed.split("|").slice(1, -1).map(c => c.trim());
rows.push(cells);
}
}
const header = rows[0] || [];
const data = rows.slice(1);
console.log("列:", header);
data.forEach(row => console.log(row));
doc.free();
Rust
let mut doc = PdfDocument::open("invoice.pdf")?;
let md = doc.to_markdown(0, false)?;
let rows: Vec<Vec<String>> = md.lines()
.map(|l| l.trim())
.filter(|l| l.starts_with('|') && !l.starts_with("|--"))
.map(|l| l.split('|').skip(1).map(|c| c.trim().to_string())
.take_while(|c| !c.is_empty()).collect())
.collect();
if let Some(header) = rows.first() {
println!("列: {:?}", header);
for row in &rows[1..] {
println!("{:?}", row);
}
}
导出为 CSV
import csv
from pdf_oxide import PdfDocument
doc = PdfDocument("invoice.pdf")
md = doc.to_markdown(0)
rows = []
for line in md.split("\n"):
line = line.strip()
if line.startswith("|") and not line.startswith("|--"):
cells = [cell.strip() for cell in line.split("|")[1:-1]]
rows.append(cells)
with open("table.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(rows)
导出为 Pandas DataFrame
import pandas as pd
from pdf_oxide import PdfDocument
doc = PdfDocument("report.pdf")
md = doc.to_markdown(0)
rows = []
for line in md.split("\n"):
line = line.strip()
if line.startswith("|") and not line.startswith("|--"):
cells = [cell.strip() for cell in line.split("|")[1:-1]]
rows.append(cells)
if rows:
df = pd.DataFrame(rows[1:], columns=rows[0])
print(df)
基于字符位置的自定义表格解析
当需要精细控制时,可以把字符级提取与空间分析结合起来:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("financial.pdf")
chars = doc.extract_chars(0)
# 按 Y 位置把字符分组(行)
rows = {}
for ch in chars:
row_key = round(ch.y / 2) * 2 # 对齐到 2 pt 网格
rows.setdefault(row_key, []).append(ch)
# 行从上到下、字符从左到右排序
for y in sorted(rows.keys(), reverse=True):
line_chars = sorted(rows[y], key=lambda c: c.x)
text = "".join(c.char for c in line_chars)
print(text)
WASM
const doc = new WasmPdfDocument(bytes);
const chars = doc.extractChars(0);
// 按 Y 位置把字符分组(行)
const rows = new Map();
for (const ch of chars) {
const rowKey = Math.round(ch.y / 2) * 2; // 对齐到 2 pt 网格
if (!rows.has(rowKey)) rows.set(rowKey, []);
rows.get(rowKey).push(ch);
}
// 行从上到下、字符从左到右排序
const sortedKeys = [...rows.keys()].sort((a, b) => b - a);
for (const y of sortedKeys) {
const lineChars = rows.get(y).sort((a, b) => a.x - b.x);
const text = lineChars.map(c => c.char).join("");
console.log(text);
}
doc.free();
Rust
use std::collections::BTreeMap;
let mut doc = PdfDocument::open("financial.pdf")?;
let chars = doc.extract_chars(0)?;
let mut rows: BTreeMap<i32, Vec<_>> = BTreeMap::new();
for ch in &chars {
let row_key = ((ch.y / 2.0).round() * 2.0) as i32;
rows.entry(row_key).or_default().push(ch);
}
for (_, line_chars) in rows.iter().rev() {
let mut sorted = line_chars.clone();
sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
let text: String = sorted.iter().map(|c| c.char).collect();
println!("{}", text);
}
Go
doc, _ := pdfoxide.Open("financial.pdf")
defer doc.Close()
chars, _ := doc.ExtractChars(0)
rows := map[int][]pdfoxide.Char{}
for _, ch := range chars {
key := int(math.Round(float64(ch.Y)/2) * 2)
rows[key] = append(rows[key], ch)
}
keys := make([]int, 0, len(rows))
for k := range rows { keys = append(keys, k) }
sort.Sort(sort.Reverse(sort.IntSlice(keys)))
for _, y := range keys {
line := rows[y]
sort.Slice(line, func(i, j int) bool { return line[i].X < line[j].X })
var b strings.Builder
for _, c := range line { b.WriteString(c.Char) }
fmt.Println(b.String())
}
C#
using var doc = PdfDocument.Open("financial.pdf");
var chars = doc.ExtractChars(0);
var rows = chars
.GroupBy(c => (int)(Math.Round(c.Y / 2) * 2))
.OrderByDescending(g => g.Key);
foreach (var row in rows)
{
var line = string.Concat(row.OrderBy(c => c.X).Select(c => c.Char));
Console.WriteLine(line);
}
把表格提取为 Markdown
当需要把 PDF 内容喂给大模型、构建 RAG 流水线,或以人机都易读的方式保存数据时,Markdown 是理想输出格式。PDF Oxide 直接原生输出 GitHub Flavored Markdown (GFM) 表格,不需要额外的转换步骤。
from pdf_oxide import PdfDocument
doc = PdfDocument("quarterly-report.pdf")
# 把全部页面的所有表格提取为 Markdown
all_tables = []
for i in range(doc.page_count()):
md = doc.to_markdown(i, detect_headings=True)
# 把 markdown 拆分成若干段,定位出表格块
in_table = False
current_table = []
for line in md.split("\n"):
if line.strip().startswith("|"):
in_table = True
current_table.append(line)
else:
if in_table and current_table:
all_tables.append("\n".join(current_table))
current_table = []
in_table = False
if current_table:
all_tables.append("\n".join(current_table))
print(f"共找到 {len(all_tables)} 个表格")
for idx, table in enumerate(all_tables):
print(f"\n--- 表 {idx + 1} ---")
print(table)
GFM 表格输出可直接用于 LLM 提示。把它原样传给 OpenAI 或 Anthropic 的 API 调用,模型不需要再额外排版就能理解表格结构:
# 把提取出的表格交给 LLM 做分析
prompt = f"""请分析下面这张财务表格,并总结关键趋势:
{all_tables[0]}
"""
这种做法比用 pdfplumber 先抽表、再手写代码转 Markdown 要快得多。
处理跨页表格
跨页表格是 PDF 提取中的老大难问题。财务报表、库存清单、监管申报里常见的表格会跨越两页、五页甚至数十页。核心思路是:每页分别提取,再把行拼接起来,期间细心处理重复表头和页面噪声。
from pdf_oxide import PdfDocument
doc = PdfDocument("long-report.pdf")
def extract_table_rows(md_text):
"""从 markdown 里提取表格行,表头与数据分开返回。"""
header = None
data_rows = []
for line in md_text.split("\n"):
line = line.strip()
if not line.startswith("|") or line.startswith("|--"):
continue
cells = [cell.strip() for cell in line.split("|")[1:-1]]
if header is None:
header = cells
else:
data_rows.append(cells)
return header, data_rows
# 汇总所有页面的行
combined_header = None
combined_rows = []
for i in range(doc.page_count()):
md = doc.to_markdown(i)
header, rows = extract_table_rows(md)
if header is None:
continue # 该页没有表格
if combined_header is None:
combined_header = header
elif header == combined_header:
pass # 忽略后续页面上重复的表头
else:
# 表格换了 —— 保存当前结果,重新开始
print(f"找到一个 {len(combined_rows)} 行的表格")
combined_header = header
combined_rows = []
combined_rows.extend(rows)
if combined_header and combined_rows:
print(f"列: {combined_header}")
print(f"总行数: {len(combined_rows)}")
for row in combined_rows[:5]:
print(row)
if len(combined_rows) > 5:
print(f"... 还有 {len(combined_rows) - 5} 行")
对于表头每页都重复出现的表格(最常见的情况),这套模式工作得很稳。如果表头只出现在首页,可以简化逻辑:只从第一页含表格的页面取表头,把后续行都当成数据。
把表格导出为 CSV 或 DataFrame
抽完表格数据后,通常要得到结构化格式以便后续分析。下面的示例展示如何只用几行代码就从 PDF 走到 pandas DataFrame 或 CSV 文件。
批量导出:每个表格一个 CSV 文件
import csv
from pdf_oxide import PdfDocument
doc = PdfDocument("catalog.pdf")
table_count = 0
for i in range(doc.page_count()):
md = doc.to_markdown(i)
rows = []
for line in md.split("\n"):
line = line.strip()
if line.startswith("|") and not line.startswith("|--"):
cells = [cell.strip() for cell in line.split("|")[1:-1]]
rows.append(cells)
if len(rows) > 1: # 至少有表头和一行数据
table_count += 1
filename = f"table_page{i + 1}_{table_count}.csv"
with open(filename, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(rows)
print(f"已保存 {filename}(数据行: {len(rows) - 1})")
print(f"共导出 {table_count} 个表格")
跨页表格转 DataFrame
对于跨页表格,把跨页拼接模式与 pandas 组合起来:
import pandas as pd
from pdf_oxide import PdfDocument
doc = PdfDocument("financial-statement.pdf")
header = None
all_rows = []
for i in range(doc.page_count()):
md = doc.to_markdown(i)
for line in md.split("\n"):
line = line.strip()
if not line.startswith("|") or line.startswith("|--"):
continue
cells = [cell.strip() for cell in line.split("|")[1:-1]]
if header is None:
header = cells
elif cells == header:
continue # 跳过重复表头
else:
all_rows.append(cells)
if header and all_rows:
df = pd.DataFrame(all_rows, columns=header)
# 清洗数值列
for col in df.columns:
# 尝试把看起来像数值的列转换类型
cleaned = df[col].str.replace(r"[$,%]", "", regex=True).str.strip()
try:
df[col] = pd.to_numeric(cleaned)
except (ValueError, TypeError):
pass # 保留为字符串
print(df.dtypes)
print(df.head(10))
df.to_csv("financial_data.csv", index=False)
跑完之后,你会得到数值类型正确的干净 DataFrame,可以直接用 pandas 分析、用 matplotlib 画图,或者写入数据库。
复杂表格:何时使用 pdfplumber
PDF Oxide 的表格检测能很好地应对标准对齐表格。遇到复杂情况 —— 合并单元格、跨列表头、无边框表格、多行单元格 —— pdfplumber 专门设计的算法更稳健:
import pdfplumber
with pdfplumber.open("complex-report.pdf") as pdf:
page = pdf.pages[0]
tables = page.extract_tables()
for table in tables:
for row in table:
print(row)
何时用哪一个
| 场景 | 推荐 |
|---|---|
| 简单的对齐表格 | PDF Oxide(快 29 倍) |
| 作为整页 Markdown 的一部分 | PDF Oxide |
| 复杂合并单元格 / 跨列表头 | pdfplumber |
| 无边框表格 | pdfplumber |
| 对速度敏感的批处理 | PDF Oxide |
两者配合使用
快速文本提取交给 PDF Oxide,复杂表格交给 pdfplumber:
from pdf_oxide import PdfDocument
import pdfplumber
# 快速的全文提取
doc = PdfDocument("report.pdf")
text = doc.extract_text(0)
# 针对复杂页面做定点表格提取
with pdfplumber.open("report.pdf") as pdf:
tables = pdf.pages[0].extract_tables()
相关页面
- Markdown 转换 — 完整的 Markdown API 参考
- 文本提取 — 纯文本与字符级提取
- PDF Oxide vs pdfplumber — 详细对比
- PDF 转 Markdown — Markdown 转换指南