Skip to content

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()

相关页面