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()
관련 문서
- Markdown 변환 — 전체 Markdown API 레퍼런스
- 텍스트 추출 — 일반 텍스트와 문자 단위 추출
- PDF Oxide vs pdfplumber — 상세 비교
- PDF를 Markdown으로 — Markdown 변환 가이드