Skip to content

Python PDF 표 추출

PDF 문서에서 표를 추출하는 일은 문서 처리 파이프라인에서 가장 흔한 작업 중 하나입니다. 연차보고서에서 재무 데이터를 가져오든, 상품 카탈로그를 긁어오든, 구조화된 데이터를 LLM에 밀어 넣든, 신뢰할 수 있는 표 추출은 필수입니다. 이 가이드는 Python에서 PDF 표를 추출할 때 알아야 할 내용을 한 번에 정리합니다. 간단한 한 줄 코드부터 여러 페이지 표를 다루는 프로덕션 워크플로까지 모두 다룹니다.

감지 엔진

PDF Oxide는 엣지 → 스냅/머지 → 교차점 → 셀 → 그룹으로 이어지는 범용 표 감지 파이프라인을 사용합니다. Tabula, pdfplumber, PyMuPDF가 쓰는 접근 방식을 순수 Rust로 구현한 것입니다.

감지 기능은 다음과 같습니다.

  • 교차점 기반 — 가로·세로 선의 교차를 찾고, 네 모서리 사각형으로 셀을 만든 뒤 union-find로 표로 묶습니다.
  • 확장 그리드 — 가로선과 세로선이 페이지의 서로 다른 영역에 있으면, 모든 좌표의 데카르트 곱으로 가상 그리드를 만듭니다.
  • 열 인식 텍스트 감지 — X 투영 히스토그램으로 2단 레이아웃을 분할한 뒤, 각 열별로 텍스트 전용 표 감지를 실행합니다.
  • H-rule로 경계 지어진 텍스트 표 — 가로선만 있고 세로선이 없는 표를 감지합니다(학술 논문에서 자주 보이는 형태).
  • 하이브리드 행 감지 — 세로 테두리만 있을 때 텍스트의 Y 위치로 행 경계를 추론합니다(송장 품목 목록 등).
  • 점선·파선 복원 — 짧은 선분을 합쳐 연속된 엣지로 만듭니다.
  • 섹션 구분선 분할 — 페이지 전체 너비의 가로 구분선에서 여러 섹션 양식을 나눕니다.
  • 엣지 커버리지 필터링 — 어떤 후보 그리드에도 참여하지 않는 고립 엣지를 제거합니다.

설정

TableDetectionConfig가 노출하는 조정 가능한 파라미터입니다.

필드 기본값 설명
horizontal_strategy "lines_strict" "lines_strict", "lines", "text", "explicit" 중 하나
vertical_strategy "lines_strict" 같은 값들
v_split_gap 20.0 pt 별도 표로 쪼갤 세로선 사이 간격(v0.3.20 이전에는 4pt 고정)
snap_tolerance 3.0 pt 근접 엣지 병합 허용 오차
text_tolerance 3.0 pt 텍스트 행 병합 허용 오차

동작 변경 사항

v0.3.20부터 Python extract_tables()의 기본 전략은 Both입니다(선과 텍스트 모두로 감지). 과거의 Text 전용 기본값에 의존하던 페이지라면 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 뷰어의 버그가 아니라 PDF 포맷 자체의 근본적인 한계 때문입니다.

PDF에는 "표"라는 개념이 없습니다. 표 구조를 <table>, <tr>, <td> 태그로 정의하는 HTML과 달리, PDF 파일은 그리기 명령만 저장합니다. “좌표 (x, y)에 이 글리프를 놓아라”, “A에서 B까지 선을 그어라” 같은 식이죠. "이 글자들은 3행 2열 셀에 속한다"라고 알려 주는 의미 계층이 존재하지 않습니다. 어떤 표 추출 라이브러리든 페이지의 텍스트와 선의 공간적 위치를 분석해 그 구조를 재구성해야 합니다.

이 재구성이 어려운 이유는 여러 가지입니다.

  • 테두리 있는 표와 없는 표. 격자선이 보이면 추출 도구는 그 선을 셀 경계로 쓸 수 있습니다. 재무제표, 정부 보고서, 학술 논문에 자주 등장하는 테두리 없는 표에는 선 자체가 없습니다. 그래서 라이브러리는 텍스트 블록 사이의 공백 간격만으로 열 경계를 추론해야 하는데, 열 너비가 가변적이거나 숫자 값이 오른쪽 정렬되어 있으면 오류가 잦습니다.

  • 병합된 셀과 여러 열을 아우르는 헤더. 세 열을 가로지르는 헤더 셀은 단일의 넓은 텍스트 블록처럼 보입니다. 격자선으로 구분되지 않으면 파서는 그 헤더가 어느 열을 덮는지 확실히 알 수 없습니다. 이를 잘 다루는 라이브러리도 있지만, 아무 말 없이 잘못된 출력을 내놓는 경우도 많습니다.

  • 여러 줄로 된 셀 내용. 셀 안에 줄바꿈되는 문단이 들어 있으면, 단순한 행 기반 파서는 각 줄을 별도 행으로 처리합니다. 그 줄들을 다시 하나의 셀로 묶으려면 각 행의 세로 범위를 이해해야 합니다.

  • 여러 페이지에 걸친 표. 큰 표는 두 페이지 이상에 걸쳐 있는 경우가 많습니다. 헤더 행이 페이지마다 반복될 수도, 아닐 수도 있고, 행 사이에 푸터, 워터마크, 페이지 번호가 끼어들기도 합니다. 이런 조각을 하나의 일관된 표로 이어 붙이려면 페이지 단위 로직이 필요합니다.

  • 회전된 텍스트와 비표준 레이아웃. 열 헤더를 회전해 쓰는 PDF도 있고, 다단 레이아웃에 표를 배치한 PDF도 있습니다. 이런 예외 상황은 대부분의 파서가 전제하는 왼쪽-오른쪽, 위-아래 읽기 순서를 무너뜨립니다.

이런 어려움을 알고 있으면 내 문서에 맞는 도구를 고르기 수월해집니다. 잘 정렬된 표가 많은 문서(대부분의 청구서, 주문 확인서, 단순 보고서)에서는 PDF Oxide 같은 빠른 공간 분석 방식이 잘 들어맞습니다. 복잡한 병합, 테두리 없는 레이아웃, 특이한 서식이 섞인 문서라면 더 정교한 휴리스틱을 갖춘 라이브러리가 필요할 수 있습니다.

표 추출: PDF Oxide와 다른 라이브러리 비교

Python에서 PDF 표 추출 라이브러리를 고를 때는 대상 문서, 성능 요구 사항, 필요한 출력 형식을 기준으로 삼아야 합니다. 주요 옵션들의 비교입니다.

라이브러리 표 감지 테두리 있는 표 테두리 없는 표 출력 형식 속도
PDF Oxide 내장 기본 Markdown/HTML 0.8ms
pdfplumber 내장 고급 Python 리스트 23.2ms
Camelot 내장 예(lattice/stream) DataFrame ~50ms+
PyMuPDF 기본(v1.23+) 제한적 DataFrame 4.6ms
pypdf 없음 없음 없음 해당 없음 해당 없음
tabula-py 내장 DataFrame ~100ms+(Java)

PDF Oxide는 월등히 빠른 선택지입니다. 정렬된 텍스트 블록의 공간 분석으로 표를 감지하고, 깔끔한 GitHub Flavored Markdown 표로 출력합니다. 평균 추출 시간 0.8ms는 pdfplumber보다 29배, tabula-py보다 100배 이상 빠릅니다. 테두리 있는 표와 단순히 정렬된 테두리 없는 표를 잘 처리합니다. 어차피 Markdown 출력이 필요한 LLM 파이프라인에서는 자연스러운 선택입니다.

pdfplumber의 테두리 없는 표 감지는 가장 성숙해 있습니다. find_tables() 메서드는 텍스트 정렬을 바탕으로 행과 열을 찾는 설정 가능한 전략을 제공하며, 병합 셀과 여러 줄 셀 내용을 대부분의 대안보다 잘 처리합니다. 대신 속도에서 값을 치릅니다. 페이지당 23.2ms는 배치 처리에서 눈에 띄게 느려집니다.

Camelot은 두 가지 감지 모드를 제공합니다. 테두리 있는 표에는 lattice, 없는 표에는 stream을 씁니다. pandas DataFrame을 바로 생성해 데이터 분석 워크플로에 편리하지만, Ghostscript와 OpenCV에 의존해 설치가 무겁고, 순수 Python 옵션 중 속도는 가장 느린 축에 속합니다.

**PyMuPDF(fitz)**는 버전 1.23에서 기본적인 표 추출을 추가했습니다. 빠르고(4.6ms) 단순한 테두리 있는 표는 잘 처리하지만, 테두리 없는 표 지원은 pdfplumber나 Camelot과 비교하면 제한적입니다.

pypdf에는 표 감지 기능이 없습니다. 원시 텍스트만 추출하므로 표 구조를 복원하려면 파서를 직접 작성해야 합니다.

tabula-py는 Java 기반 Tabula 라이브러리를 Python에서 감싼 래퍼입니다. 테두리 있는 표와 없는 표 모두에서 좋은 감지 성능을 보여 주지만, Java 런타임이 필요하고 JVM 기동 오버헤드 때문에 가장 느립니다. 대용량 파이프라인보다 일회성 추출 작업에 더 적합합니다.

대부분의 프로덕션 사례에서는 속도와 단순함을 위해 PDF Oxide를 기본 추출기로 쓰고, 고급 휴리스틱이 필요한 복잡한 표 레이아웃의 문서에 한해 pdfplumber로 폴백하는 조합이 권장됩니다.

설치

pip install pdf_oxide

기본 표 추출

Markdown 표로 추출

가장 간단한 방법은 페이지를 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  # 2pt 격자에 스냅
    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; // 2pt 격자에 스냅
    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:  # 헤더와 데이터 행이 최소 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()

관련 문서