Python으로 PDF에서 표 추출하기
PDF 문서에서 표를 추출하는 것은 문서 처리 파이프라인에서 가장 흔한 작업 중 하나입니다. 연간 보고서에서 재무 데이터를 가져오거나, 제품 카탈로그를 스크래핑하거나, 구조화된 데이터를 LLM에 입력하는 경우 모두 신뢰할 수 있는 표 추출이 필수적입니다. 이 가이드는 Python에서 PDF 표를 추출하는 데 필요한 모든 것을 다룹니다. 간단한 한 줄 코드부터 여러 페이지에 걸친 표를 처리하는 프로덕션 수준의 워크플로까지 포괄합니다.
감지 엔진
PDF Oxide는 엣지 → 스냅/병합 → 교차점 → 셀 → 그룹의 범용 표 감지 파이프라인을 사용합니다. 이는 Tabula, pdfplumber, PyMuPDF와 동일한 접근 방식으로, 순수 Rust로 구현되어 있습니다.
감지 기능:
- 교차점 기반 — 수평×수직 선의 교차를 찾고, 네 모서리 직사각형으로 셀을 구성하며, union-find로 표로 그룹화합니다.
- 확장 그리드 — 수평선과 수직선이 페이지의 서로 다른 영역에 있을 때, 모든 좌표의 데카르트 곱으로 가상 그리드를 구성합니다.
- 열 인식 텍스트 감지 — X 투영 히스토그램을 통해 2단 레이아웃을 분할한 후, 각 열에서 텍스트 전용 표 감지를 실행합니다.
- 수평선으로 경계가 구분된 텍스트 표 — 수직선 없이 수평선으로만 경계가 구분된 표를 감지합니다(학술 논문에서 흔히 사용).
- 하이브리드 행 감지 — 수직 테두리만 있을 때 텍스트의 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(선과 텍스트 모두로 감지)입니다. 기존의 텍스트 전용 기본값에 의존하던 페이지는 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)
# Output includes tables in GFM format:
# | 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);
// Output includes tables in GFM format:
// | 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));
Java
import fyi.oxide.pdf.PdfDocument;
try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("invoice.pdf"))) {
System.out.println(doc.toMarkdown(0));
}
PHP
use PdfOxide\PdfDocument;
$doc = PdfDocument::open('invoice.pdf');
echo $doc->toMarkdown(0);
$doc->close();
Ruby
require 'pdf_oxide'
PdfOxide::PdfDocument.open('invoice.pdf') do |doc|
puts doc.to_markdown(0)
end
C++
#include <pdf_oxide/pdf_oxide.hpp>
#include <iostream>
auto doc = pdf_oxide::Document::open("invoice.pdf");
std::cout << doc.to_markdown(0) << '\n';
Swift
import PdfOxide
let doc = try Document.open("invoice.pdf")
print(try doc.toMarkdown(0))
Kotlin
import fyi.oxide.pdf.PdfDocument
PdfDocument.open(java.nio.file.Path.of("invoice.pdf")).use { doc ->
println(doc.toMarkdown(0))
}
Dart
import 'package:pdf_oxide/pdf_oxide.dart';
final doc = PdfDocument.open('invoice.pdf');
print(doc.toMarkdown(0));
doc.close();
R
library(pdfoxide)
doc <- pdf_open("invoice.pdf")
cat(pdf_to_markdown(doc, 0))
Julia
using PdfOxide
doc = open_document("invoice.pdf")
println(to_markdown(doc, 0))
Zig
const pdf_oxide = @import("pdf_oxide");
const a = std.heap.page_allocator;
var doc = try pdf_oxide.Document.open("invoice.pdf");
const md = try doc.toMarkdown(a, 0);
defer a.free(md);
std.debug.print("{s}\n", .{md});
Scala
import fyi.oxide.pdf.PdfDocument
import scala.util.Using
Using.resource(PdfDocument.open("invoice.pdf")) { doc =>
println(doc.toMarkdown(0))
}
Clojure
(require '[pdf-oxide.core :as pdf])
(with-open [d (pdf/open "invoice.pdf")]
(println (pdf/to-markdown d 0)))
Objective-C
#import "POXPdfOxide.h"
NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"invoice.pdf" error:&err];
NSLog(@"%@", [doc toMarkdown:0 error:&err]);
Elixir
{:ok, doc} = PdfOxide.open("invoice.pdf")
{:ok, md} = PdfOxide.to_markdown(doc, 0)
IO.puts(md)
PDF Oxide는 정렬된 텍스트 블록의 공간 분석으로 표 레이아웃을 감지하고 GitHub Flavored Markdown 표로 출력합니다.
PDF에서 표 추출이 어려운 이유
PDF에서 표를 복사해 스프레드시트에 붙여넣어 본 적이 있다면, 결과가 대부분 엉망이라는 것을 알 것입니다. 이는 PDF 뷰어의 버그가 아니라 PDF 형식 자체의 근본적인 한계를 반영합니다.
PDF에는 "표"라는 개념이 없습니다. HTML이 <table>, <tr>, <td> 태그로 표 구조를 정의하는 것과 달리, PDF 파일은 그리기 명령만 저장합니다: 좌표 (x, y)에 이 글리프를 배치하고, 점 A에서 점 B로 선을 그립니다. "이 문자들은 3행 2열 셀에 속한다"고 알려주는 의미 레이어가 없습니다. 모든 표 추출 라이브러리는 페이지의 텍스트와 선의 공간적 위치를 분석하여 그 구조를 재구성해야 합니다.
이 재구성이 어려운 이유는 여러 가지입니다:
-
테두리 있는 표 vs 없는 표. 표에 눈에 보이는 격자선이 있으면 추출 도구는 그 선을 셀 경계로 사용할 수 있습니다. 테두리 없는 표—재무제표, 정부 보고서, 학술 논문에서 흔한—는 선이 전혀 없습니다. 라이브러리는 텍스트 블록 사이의 공백 간격만으로 열 경계를 추론해야 하는데, 열 너비가 가변적이거나 숫자가 우측 정렬된 경우 오류가 발생하기 쉽습니다.
-
병합 셀과 병합 헤더. 세 열에 걸친 헤더 셀은 하나의 넓은 텍스트 블록처럼 보입니다. 경계를 구분하는 격자선 없이는 파서가 헤더가 어떤 열을 커버하는지 신뢰성 있게 알 수 없습니다. 일부 라이브러리는 이를 잘 처리하지만, 많은 경우 자동으로 깨진 출력을 생성합니다.
-
여러 줄의 셀 내용. 셀에 여러 줄로 줄바꿈되는 텍스트가 있을 때, 단순한 행 기반 파싱은 각 줄바꿈을 별도의 행으로 처리합니다. 이 줄들을 하나의 셀로 올바르게 그룹화하려면 각 행의 수직 범위를 이해해야 합니다.
-
여러 페이지에 걸친 표. 큰 표는 종종 두 페이지 이상에 걸쳐 있습니다. 헤더 행이 각 페이지에서 반복될 수도 있고 반복되지 않을 수도 있으며, 페이지 바닥글, 워터마크, 페이지 번호가 표 행 사이에 나타날 수 있습니다. 이러한 조각들을 하나의 일관된 표로 이어 붙이려면 페이지를 인식하는 로직이 필요합니다.
-
회전된 텍스트와 비표준 레이아웃. 일부 PDF는 열 헤더에 회전된 텍스트를 사용하거나, 다단 페이지 레이아웃에 표를 배치합니다. 이러한 엣지 케이스는 대부분의 파서가 가정하는 좌에서 우, 위에서 아래로의 읽기 순서를 깨뜨립니다.
이러한 과제를 이해하면 특정 문서에 맞는 올바른 도구를 선택하는 데 도움이 됩니다. 단순하게 정렬된 표—대부분의 청구서, 주문 확인서, 간단한 보고서—에는 PDF Oxide와 같은 빠른 공간 분석 접근 방식이 잘 작동합니다. 복잡한 병합, 테두리 없는 레이아웃, 또는 특이한 형식이 있는 문서에는 더 정교한 휴리스틱이 있는 라이브러리가 필요할 수 있습니다.
표 추출: PDF Oxide vs 다른 라이브러리
Python에서 PDF 표 추출 라이브러리를 선택하는 것은 문서, 성능 요구 사항, 필요한 출력 형식에 따라 다릅니다. 주요 옵션을 비교하면 다음과 같습니다:
| 라이브러리 | 표 감지 | 테두리 있는 표 | 테두리 없는 표 | 출력 형식 | 속도 |
|---|---|---|---|---|---|
| PDF Oxide | 내장 | 예 | 기본 | Markdown/HTML | 0.8ms |
| pdfplumber | 내장 | 예 | 고급 | Python 리스트 | 23.2ms |
| Camelot | 내장 | 예 | 예 (lattice/stream) | DataFrames | ~50ms+ |
| PyMuPDF | 기본 (v1.23+) | 예 | 제한적 | DataFrames | 4.6ms |
| pypdf | 없음 | 없음 | 없음 | N/A | N/A |
| tabula-py | 내장 | 예 | 예 | DataFrames | ~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 표로 출력
가장 간단한 접근 방식—페이지를 GFM 구문의 표가 포함된 Markdown으로 변환합니다:
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: # Page contains a table
print(f"--- Page {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("|")) { // Page contains a table
console.log(`--- Page ${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!("--- Page {} ---", 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("--- Page %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($"--- Page {i + 1} ---\n{md}");
}
Java
try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("report.pdf"))) {
for (int i = 0; i < doc.pageCount(); i++) {
String md = doc.toMarkdown(i);
if (md.contains("|")) { // Page contains a table
System.out.println("--- Page " + (i + 1) + " ---");
System.out.println(md);
}
}
}
PHP
$doc = PdfDocument::open('report.pdf');
for ($i = 0; $i < $doc->pageCount(); $i++) {
$md = $doc->toMarkdown($i);
if (str_contains($md, '|')) { // Page contains a table
echo "--- Page " . ($i + 1) . " ---\n";
echo $md;
}
}
$doc->close();
Ruby
PdfOxide::PdfDocument.open('report.pdf') do |doc|
doc.page_count.times do |i|
md = doc.to_markdown(i)
if md.include?('|') # Page contains a table
puts "--- Page #{i + 1} ---"
puts md
end
end
end
C++
auto doc = pdf_oxide::Document::open("report.pdf");
for (int i = 0; i < doc.page_count(); i++) {
auto md = doc.to_markdown(i);
if (md.find('|') != std::string::npos) { // Page contains a table
std::cout << "--- Page " << (i + 1) << " ---\n" << md << '\n';
}
}
Swift
let doc = try Document.open("report.pdf")
for i in 0..<(try doc.pageCount()) {
let md = try doc.toMarkdown(i)
if md.contains("|") { // Page contains a table
print("--- Page \(i + 1) ---")
print(md)
}
}
Kotlin
PdfDocument.open(java.nio.file.Path.of("report.pdf")).use { doc ->
for (i in 0 until doc.pageCount()) {
val md = doc.toMarkdown(i)
if (md.contains("|")) { // Page contains a table
println("--- Page ${i + 1} ---")
println(md)
}
}
}
Dart
final doc = PdfDocument.open('report.pdf');
for (var i = 0; i < doc.pageCount; i++) {
final md = doc.toMarkdown(i);
if (md.contains('|')) { // Page contains a table
print('--- Page ${i + 1} ---');
print(md);
}
}
doc.close();
R
doc <- pdf_open("report.pdf")
for (i in 0:(pdf_page_count(doc) - 1)) {
md <- pdf_to_markdown(doc, i)
if (grepl("\\|", md)) { # Page contains a table
cat(sprintf("--- Page %d ---\n%s\n", i + 1, md))
}
}
Julia
doc = open_document("report.pdf")
for i in 0:(page_count(doc) - 1)
md = to_markdown(doc, i)
if occursin("|", md) # Page contains a table
println("--- Page $(i + 1) ---")
println(md)
end
end
Zig
var doc = try pdf_oxide.Document.open("report.pdf");
const n = try doc.pageCount();
var i: i32 = 0;
while (i < n) : (i += 1) {
const md = try doc.toMarkdown(a, i);
defer a.free(md);
if (std.mem.indexOfScalar(u8, md, '|') != null) { // Page contains a table
std.debug.print("--- Page {d} ---\n{s}\n", .{ i + 1, md });
}
}
Scala
Using.resource(PdfDocument.open("report.pdf")) { doc =>
for (i <- 0 until doc.pageCount()) {
val md = doc.toMarkdown(i)
if (md.contains("|")) { // Page contains a table
println(s"--- Page ${i + 1} ---")
println(md)
}
}
}
Clojure
(with-open [d (pdf/open "report.pdf")]
(doseq [i (range (pdf/page-count d))]
(let [md (pdf/to-markdown d i)]
(when (.contains md "|") ; Page contains a table
(println (str "--- Page " (inc i) " ---"))
(println md)))))
Objective-C
NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"report.pdf" error:&err];
for (NSInteger i = 0; i < [doc pageCountError:&err]; i++) {
NSString *md = [doc toMarkdown:i error:&err];
if ([md containsString:@"|"]) { // Page contains a table
NSLog(@"--- Page %ld ---\n%@", (long)(i + 1), md);
}
}
Elixir
{:ok, doc} = PdfOxide.open("report.pdf")
{:ok, n} = PdfOxide.page_count(doc)
for i <- 0..(n - 1) do
{:ok, md} = PdfOxide.to_markdown(doc, i)
if String.contains?(md, "|") do # Page contains a table
IO.puts("--- Page #{i + 1} ---")
IO.puts(md)
end
end
구조화된 표 추출 (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));
Java
import fyi.oxide.pdf.PdfDocument;
import fyi.oxide.pdf.table.Table;
import fyi.oxide.pdf.table.TableCell;
try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("invoice.pdf"))) {
for (Table table : doc.page(0).tables()) {
String[][] grid = new String[table.rows()][table.cols()];
for (TableCell c : table.cells()) grid[c.row()][c.col()] = c.text();
for (String[] row : grid) System.out.println(String.join(" | ", row));
}
}
C++
auto doc = pdf_oxide::Document::open("invoice.pdf");
for (const auto& table : doc.extract_tables(0)) {
for (int r = 0; r < table.row_count; r++) {
for (int c = 0; c < table.col_count; c++) {
std::cout << table.cell(r, c);
if (c + 1 < table.col_count) std::cout << " | ";
}
std::cout << '\n';
}
}
Swift
let doc = try Document.open("invoice.pdf")
for table in try doc.extractTables(0) {
for r in 0..<table.rowCount {
let row = (0..<table.colCount).map { table.cell(r, $0) }
print(row.joined(separator: " | "))
}
}
Kotlin
import fyi.oxide.pdf.PdfDocument
PdfDocument.open(java.nio.file.Path.of("invoice.pdf")).use { doc ->
for (table in doc.page(0).tables()) {
val grid = Array(table.rows()) { arrayOfNulls<String>(table.cols()) }
table.cells().forEach { grid[it.row()][it.col()] = it.text() }
grid.forEach { println(it.joinToString(" | ")) }
}
}
Dart
final doc = PdfDocument.open('invoice.pdf');
for (final table in doc.extractTables(0)) {
for (var r = 0; r < table.rowCount; r++) {
final row = [for (var c = 0; c < table.colCount; c++) table.cell(r, c)];
print(row.join(' | '));
}
}
doc.close();
R
doc <- pdf_open("invoice.pdf")
for (table in pdf_extract_tables(doc, 0)) {
for (r in seq_len(table$row_count)) {
cat(paste(table$cells[r, ], collapse = " | "), "\n")
}
}
Julia
doc = open_document("invoice.pdf")
for table in extract_tables(doc, 0)
for r in 1:table.row_count
println(join(table.cells[r, :], " | "))
end
end
Zig
var doc = try pdf_oxide.Document.open("invoice.pdf");
const tables = try doc.extractTables(a, 0);
defer pdf_oxide.Document.freeTables(a, tables);
for (tables) |table| {
var r: i32 = 0;
while (r < table.rowCount) : (r += 1) {
var c: i32 = 0;
while (c < table.colCount) : (c += 1) {
std.debug.print("{s}", .{table.cell(r, c)});
if (c + 1 < table.colCount) std.debug.print(" | ", .{});
}
std.debug.print("\n", .{});
}
}
Scala
import fyi.oxide.pdf.PdfDocument
import scala.jdk.CollectionConverters._
import scala.util.Using
Using.resource(PdfDocument.open("invoice.pdf")) { doc =>
for (table <- doc.page(0).tables().asScala) {
val grid = Array.ofDim[String](table.rows(), table.cols())
table.cells().asScala.foreach(c => grid(c.row())(c.col()) = c.text())
grid.foreach(row => println(row.mkString(" | ")))
}
}
Clojure
(with-open [d (pdf/open "invoice.pdf")]
(doseq [table (pdf/tables (pdf/page d 0))]
(let [grid (make-array String (.rows table) (.cols table))]
(doseq [c (.cells table)]
(aset grid (.row c) (.col c) (.text c)))
(doseq [row grid]
(println (clojure.string/join " | " row))))))
Objective-C
NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"invoice.pdf" error:&err];
for (POXTable *table in [doc extractTables:0 error:&err]) {
for (NSInteger r = 0; r < table.rowCount; r++) {
NSMutableArray<NSString *> *row = [NSMutableArray array];
for (NSInteger c = 0; c < table.colCount; c++)
[row addObject:([table cellTextAtRow:r col:c] ?: @"")];
NSLog(@"%@", [row componentsJoinedByString:@" | "]);
}
}
Elixir
{:ok, doc} = PdfOxide.open("invoice.pdf")
{:ok, tables} = PdfOxide.extract_tables(doc, 0)
for table <- tables do
for r <- 0..(table.row_count - 1) do
row = for c <- 0..(table.col_count - 1), do: PdfOxide.cell(table, r, c)
IO.puts(Enum.join(row, " | "))
end
end
Markdown 표를 행으로 파싱하기
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("invoice.pdf")
md = doc.to_markdown(0)
# Extract table rows from 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"Columns: {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("Columns:", 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!("Columns: {:?}", header);
for row in &rows[1..] {
println!("{:?}", row);
}
}
Java
import fyi.oxide.pdf.PdfDocument;
import java.util.*;
try (PdfDocument doc = PdfDocument.open(java.nio.file.Path.of("invoice.pdf"))) {
String md = doc.toMarkdown(0);
List<List<String>> rows = new ArrayList<>();
for (String line : md.split("\n")) {
line = line.strip();
if (line.startsWith("|") && !line.startsWith("|--")) {
String[] parts = line.substring(1, line.length() - 1).split("\\|");
List<String> cells = new ArrayList<>();
for (String p : parts) cells.add(p.strip());
rows.add(cells);
}
}
System.out.println("Columns: " + (rows.isEmpty() ? List.of() : rows.get(0)));
for (int i = 1; i < rows.size(); i++) System.out.println(rows.get(i));
}
PHP
$doc = PdfDocument::open('invoice.pdf');
$md = $doc->toMarkdown(0);
$rows = [];
foreach (explode("\n", $md) as $line) {
$line = trim($line);
if (str_starts_with($line, '|') && !str_starts_with($line, '|--')) {
$cells = array_map('trim', array_slice(explode('|', $line), 1, -1));
$rows[] = $cells;
}
}
$header = $rows[0] ?? [];
echo "Columns: " . implode(', ', $header) . "\n";
foreach (array_slice($rows, 1) as $row) {
echo implode(' | ', $row) . "\n";
}
$doc->close();
Ruby
PdfOxide::PdfDocument.open('invoice.pdf') do |doc|
md = doc.to_markdown(0)
rows = md.lines.map(&:strip)
.select { |l| l.start_with?('|') && !l.start_with?('|--') }
.map { |l| l.split('|')[1..-2].map(&:strip) }
header = rows.first || []
puts "Columns: #{header.inspect}"
rows.drop(1).each { |row| puts row.inspect }
end
C++
#include <pdf_oxide/pdf_oxide.hpp>
#include <sstream>
#include <vector>
auto doc = pdf_oxide::Document::open("invoice.pdf");
auto md = doc.to_markdown(0);
std::vector<std::vector<std::string>> rows;
std::istringstream stream(md);
for (std::string line; std::getline(stream, line);) {
auto s = line.find_first_not_of(" \t");
if (s == std::string::npos) continue;
line = line.substr(s);
if (line.rfind("|", 0) != 0 || line.rfind("|--", 0) == 0) continue;
std::vector<std::string> cells;
std::istringstream cs(line.substr(1, line.size() - 2));
for (std::string cell; std::getline(cs, cell, '|');) cells.push_back(cell);
rows.push_back(cells);
}
Swift
let doc = try Document.open("invoice.pdf")
let md = try doc.toMarkdown(0)
let rows = md.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }
.filter { $0.hasPrefix("|") && !$0.hasPrefix("|--") }
.map { line -> [String] in
line.dropFirst().dropLast().split(separator: "|", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespaces) }
}
if let header = rows.first {
print("Columns:", header)
for row in rows.dropFirst() { print(row) }
}
Kotlin
PdfDocument.open(java.nio.file.Path.of("invoice.pdf")).use { doc ->
val md = doc.toMarkdown(0)
val rows = md.split("\n").map { it.trim() }
.filter { it.startsWith("|") && !it.startsWith("|--") }
.map { it.removeSurrounding("|").split("|").map(String::trim) }
rows.firstOrNull()?.let { println("Columns: $it") }
rows.drop(1).forEach { println(it) }
}
Dart
final doc = PdfDocument.open('invoice.pdf');
final md = doc.toMarkdown(0);
final rows = md.split('\n').map((l) => l.trim())
.where((l) => l.startsWith('|') && !l.startsWith('|--'))
.map((l) => l.substring(1, l.length - 1).split('|').map((c) => c.trim()).toList())
.toList();
if (rows.isNotEmpty) {
print('Columns: ${rows.first}');
for (final row in rows.skip(1)) print(row);
}
doc.close();
R
doc <- pdf_open("invoice.pdf")
md <- pdf_to_markdown(doc, 0)
lines <- trimws(strsplit(md, "\n")[[1]])
lines <- lines[startsWith(lines, "|") & !startsWith(lines, "|--")]
rows <- lapply(lines, function(l) {
cells <- strsplit(l, "\\|")[[1]]
trimws(cells[2:(length(cells) - 1)])
})
if (length(rows) > 0) {
cat("Columns:", rows[[1]], "\n")
for (row in rows[-1]) cat(row, "\n")
}
Julia
doc = open_document("invoice.pdf")
md = to_markdown(doc, 0)
rows = [strip.(split(l, "|")[2:end-1])
for l in strip.(split(md, "\n"))
if startswith(l, "|") && !startswith(l, "|--")]
if !isempty(rows)
println("Columns: ", rows[1])
for row in rows[2:end]
println(row)
end
end
Zig
var doc = try pdf_oxide.Document.open("invoice.pdf");
const md = try doc.toMarkdown(a, 0);
defer a.free(md);
var lines = std.mem.splitScalar(u8, md, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (!std.mem.startsWith(u8, line, "|") or std.mem.startsWith(u8, line, "|--")) continue;
const inner = line[1 .. line.len - 1];
var cells = std.mem.splitScalar(u8, inner, '|');
while (cells.next()) |cell| {
std.debug.print("{s}\t", .{std.mem.trim(u8, cell, " \t")});
}
std.debug.print("\n", .{});
}
Scala
Using.resource(PdfDocument.open("invoice.pdf")) { doc =>
val md = doc.toMarkdown(0)
val rows = md.split("\n").map(_.trim)
.filter(l => l.startsWith("|") && !l.startsWith("|--"))
.map(_.stripPrefix("|").stripSuffix("|").split("\\|").map(_.trim).toList)
.toList
rows.headOption.foreach(h => println(s"Columns: $h"))
rows.drop(1).foreach(println)
}
Clojure
(with-open [d (pdf/open "invoice.pdf")]
(let [md (pdf/to-markdown d 0)
rows (->> (clojure.string/split-lines md)
(map clojure.string/trim)
(filter #(and (.startsWith % "|") (not (.startsWith % "|--"))))
(map #(->> (clojure.string/split % #"\|")
(drop 1) (butlast) (map clojure.string/trim) vec)))]
(when-let [header (first rows)]
(println "Columns:" header)
(doseq [row (rest rows)] (println row)))))
Objective-C
NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"invoice.pdf" error:&err];
NSString *md = [doc toMarkdown:0 error:&err];
NSMutableArray<NSArray<NSString *> *> *rows = [NSMutableArray array];
for (NSString *raw in [md componentsSeparatedByString:@"\n"]) {
NSString *line = [raw stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
if (![line hasPrefix:@"|"] || [line hasPrefix:@"|--"]) continue;
NSArray<NSString *> *parts = [line componentsSeparatedByString:@"|"];
NSMutableArray<NSString *> *cells = [NSMutableArray array];
for (NSUInteger i = 1; i + 1 < parts.count; i++)
[cells addObject:[parts[i] stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]]];
[rows addObject:cells];
}
if (rows.count > 0) NSLog(@"Columns: %@", rows[0]);
Elixir
{:ok, doc} = PdfOxide.open("invoice.pdf")
{:ok, md} = PdfOxide.to_markdown(doc, 0)
rows =
md
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(&(String.starts_with?(&1, "|") and not String.starts_with?(&1, "|--")))
|> Enum.map(fn line ->
line |> String.split("|") |> Enum.slice(1..-2//1) |> Enum.map(&String.trim/1)
end)
case rows do
[header | data] ->
IO.puts("Columns: #{inspect(header)}")
Enum.each(data, &IO.inspect/1)
[] ->
:ok
end
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)
# Group characters by Y position (rows)
rows = {}
for ch in chars:
row_key = round(ch.y / 2) * 2 # Snap to 2pt grid
rows.setdefault(row_key, []).append(ch)
# Sort rows top-to-bottom, characters left-to-right
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);
// Group characters by Y position (rows)
const rows = new Map();
for (const ch of chars) {
const rowKey = Math.round(ch.y / 2) * 2; // Snap to 2pt grid
if (!rows.has(rowKey)) rows.set(rowKey, []);
rows.get(rowKey).push(ch);
}
// Sort rows top-to-bottom, characters left-to-right
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);
}
C++
#include <pdf_oxide/pdf_oxide.hpp>
#include <map>
#include <vector>
#include <algorithm>
auto doc = pdf_oxide::Document::open("financial.pdf");
auto chars = doc.extract_chars(0);
// Group characters by Y position (rows)
std::map<int, std::vector<pdf_oxide::Char>> rows;
for (const auto& ch : chars) {
int key = static_cast<int>(std::lround(ch.bbox.y / 2.0) * 2);
rows[key].push_back(ch);
}
// Sort rows top-to-bottom, characters left-to-right
for (auto it = rows.rbegin(); it != rows.rend(); ++it) {
auto& line = it->second;
std::sort(line.begin(), line.end(),
[](const auto& a, const auto& b) { return a.bbox.x < b.bbox.x; });
std::string text;
for (const auto& c : line) text += static_cast<char>(c.character);
std::cout << text << '\n';
}
Swift
let doc = try Document.open("financial.pdf")
let chars = try doc.extractChars(0)
// Group characters by Y position (rows)
var rows: [Int: [Char]] = [:]
for ch in chars {
let key = Int((ch.bbox.y / 2).rounded()) * 2 // Snap to 2pt grid
rows[key, default: []].append(ch)
}
// Sort rows top-to-bottom, characters left-to-right
for y in rows.keys.sorted(by: >) {
let line = rows[y]!.sorted { $0.bbox.x < $1.bbox.x }
let text = String(line.compactMap { Unicode.Scalar($0.character).map(Character.init) })
print(text)
}
Dart
final doc = PdfDocument.open('financial.pdf');
final chars = doc.extractChars(0);
// Group characters by Y position (rows)
final rows = <int, List<Char>>{};
for (final ch in chars) {
final key = (ch.bbox.y / 2).round() * 2; // Snap to 2pt grid
rows.putIfAbsent(key, () => []).add(ch);
}
// Sort rows top-to-bottom, characters left-to-right
final keys = rows.keys.toList()..sort((a, b) => b - a);
for (final y in keys) {
final line = rows[y]!..sort((a, b) => a.bbox.x.compareTo(b.bbox.x));
final text = String.fromCharCodes(line.map((c) => c.character));
print(text);
}
doc.close();
R
doc <- pdf_open("financial.pdf")
chars <- pdf_extract_chars(doc, 0)
# Group characters by Y position (rows), snapped to a 2pt grid
keys <- sapply(chars, function(ch) round(ch$bbox$y / 2) * 2)
for (y in sort(unique(keys), decreasing = TRUE)) {
line <- chars[keys == y]
line <- line[order(sapply(line, function(c) c$bbox$x))]
text <- paste(intToUtf8(sapply(line, function(c) c$character), multiple = TRUE),
collapse = "")
cat(text, "\n")
}
Julia
doc = open_document("financial.pdf")
chars = extract_chars(doc, 0)
# Group characters by Y position (rows), snapped to a 2pt grid
rows = Dict{Int,Vector}()
for ch in chars
key = round(Int, ch.bbox.y / 2) * 2
push!(get!(rows, key, []), ch)
end
for y in sort(collect(keys(rows)), rev = true)
line = sort(rows[y], by = c -> c.bbox.x)
text = join(Char.(getfield.(line, :character)))
println(text)
end
Zig
var doc = try pdf_oxide.Document.open("financial.pdf");
const chars = try doc.extractChars(a, 0);
defer pdf_oxide.Document.freeChars(a, chars);
// Group characters by Y position (rows)
var rows = std.AutoArrayHashMap(i32, std.ArrayList(pdf_oxide.Char)).init(a);
for (chars) |ch| {
const key: i32 = @intFromFloat(@round(ch.bbox.y / 2.0) * 2.0);
const gop = try rows.getOrPut(key);
if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(pdf_oxide.Char).init(a);
try gop.value_ptr.append(ch);
}
// Sort keys descending (top-to-bottom), characters left-to-right
const keys = rows.keys();
std.mem.sort(i32, keys, {}, comptime std.sort.desc(i32));
for (keys) |y| {
var line = rows.get(y).?;
std.mem.sort(pdf_oxide.Char, line.items, {}, struct {
fn lt(_: void, x: pdf_oxide.Char, z: pdf_oxide.Char) bool { return x.bbox.x < z.bbox.x; }
}.lt);
for (line.items) |c| {
var buf: [4]u8 = undefined;
const len = std.unicode.utf8Encode(@intCast(c.character), &buf) catch 0;
std.debug.print("{s}", .{buf[0..len]});
}
std.debug.print("\n", .{});
}
Objective-C
NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"financial.pdf" error:&err];
NSArray<POXChar *> *chars = [doc extractChars:0 error:&err];
// Group characters by Y position (rows)
NSMutableDictionary<NSNumber *, NSMutableArray<POXChar *> *> *rows =
[NSMutableDictionary dictionary];
for (POXChar *ch in chars) {
NSNumber *key = @((NSInteger)(round(ch.bbox.y / 2.0) * 2));
if (!rows[key]) rows[key] = [NSMutableArray array];
[rows[key] addObject:ch];
}
// Sort rows top-to-bottom, characters left-to-right
NSArray<NSNumber *> *keys = [[rows allKeys]
sortedArrayUsingSelector:@selector(compare:)];
for (NSNumber *y in [keys reverseObjectEnumerator]) {
NSArray<POXChar *> *line = [rows[y] sortedArrayUsingComparator:
^(POXChar *x, POXChar *z) { return [@(x.bbox.x) compare:@(z.bbox.x)]; }];
NSMutableString *text = [NSMutableString string];
for (POXChar *c in line)
[text appendString:[[NSString alloc] initWithBytes:&(uint32_t){c.character}
length:4 encoding:NSUTF32LittleEndianStringEncoding]];
NSLog(@"%@", text);
}
Elixir
{:ok, doc} = PdfOxide.open("financial.pdf")
{:ok, chars} = PdfOxide.extract_chars(doc, 0)
# Group characters by Y position (rows), snapped to a 2pt grid
chars
|> Enum.group_by(fn ch -> round(ch.bbox.y / 2) * 2 end)
|> Enum.sort_by(fn {y, _} -> -y end)
|> Enum.each(fn {_y, line} ->
text =
line
|> Enum.sort_by(fn c -> c.bbox.x end)
|> Enum.map(fn c -> <<c.character::utf8>> end)
|> Enum.join()
IO.puts(text)
end)
표를 Markdown으로 내보내기
Markdown은 PDF 내용을 대형 언어 모델에 제공하거나, RAG 파이프라인을 구축하거나, 사람이 읽을 수 있고 기계가 파싱할 수 있는 형식으로 추출 데이터를 저장할 때 이상적인 출력 형식입니다. PDF Oxide는 GitHub Flavored Markdown (GFM) 형식으로 표를 기본 출력하므로 추가 변환 단계가 필요 없습니다.
from pdf_oxide import PdfDocument
doc = PdfDocument("quarterly-report.pdf")
# Extract all tables across all pages as Markdown
all_tables = []
for i in range(doc.page_count()):
md = doc.to_markdown(i, detect_headings=True)
# Split the markdown into sections and find table blocks
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"Found {len(all_tables)} tables")
for idx, table in enumerate(all_tables):
print(f"\n--- Table {idx + 1} ---")
print(table)
GFM 표 출력은 LLM 프롬프트와 직접 호환됩니다. 추가 형식 변환 없이 OpenAI나 Anthropic API 호출에 그대로 전달하면 모델이 표 구조를 이해합니다:
# Feed extracted table to an LLM for analysis
prompt = f"""Analyze the following financial table and summarize the key trends:
{all_tables[0]}
"""
이 접근 방식은 pdfplumber로 표를 추출한 후 직접 Markdown으로 변환하는 것보다 훨씬 빠릅니다.
여러 페이지에 걸친 표 처리
여러 페이지에 걸친 표는 PDF 추출에서 흔한 과제입니다. 재무제표, 재고 목록, 규정 서류에는 두 페이지, 다섯 페이지, 심지어 수십 페이지에 걸쳐 있는 표가 자주 포함됩니다. 핵심은 각 페이지에서 표를 개별로 추출한 후, 반복되는 헤더와 페이지 아티팩트를 주의 깊게 처리하며 행을 이어 붙이는 것입니다.
from pdf_oxide import PdfDocument
doc = PdfDocument("long-report.pdf")
def extract_table_rows(md_text):
"""Extract table rows from markdown text, returning header and data separately."""
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
# Collect rows across all pages
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 # No table on this page
if combined_header is None:
combined_header = header
elif header == combined_header:
pass # Skip repeated header on subsequent pages
else:
# Different table — save current and start new
print(f"Table with {len(combined_rows)} rows found")
combined_header = header
combined_rows = []
combined_rows.extend(rows)
if combined_header and combined_rows:
print(f"Columns: {combined_header}")
print(f"Total rows: {len(combined_rows)}")
for row in combined_rows[:5]:
print(row)
if len(combined_rows) > 5:
print(f"... and {len(combined_rows) - 5} more rows")
이 패턴은 각 페이지에서 헤더 행이 반복되는 표(가장 일반적인 경우)에 안정적으로 작동합니다. 헤더가 첫 번째 페이지에만 나타나는 표의 경우, 표가 있는 첫 번째 페이지에서만 헤더를 캡처하고 이후 모든 행을 데이터로 처리하면 로직을 단순화할 수 있습니다.
표를 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: # At least header + one data row
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"Saved {filename} ({len(rows) - 1} data rows)")
print(f"Exported {table_count} tables total")
여러 페이지 표를 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 # Skip repeated header
else:
all_rows.append(cells)
if header and all_rows:
df = pd.DataFrame(all_rows, columns=header)
# Clean up numeric columns
for col in df.columns:
# Try to convert columns that look numeric
cleaned = df[col].str.replace(r"[$,%]", "", regex=True).str.strip()
try:
df[col] = pd.to_numeric(cleaned)
except (ValueError, TypeError):
pass # Keep as string
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
# Fast full-text extraction
doc = PdfDocument("report.pdf")
text = doc.extract_text(0)
# Targeted table extraction for complex pages
with pdfplumber.open("report.pdf") as pdf:
tables = pdf.pages[0].extract_tables()
관련 페이지
- Markdown 변환 — 전체 Markdown API 참조
- 텍스트 추출 — 일반 텍스트 및 문자 추출
- PDF Oxide vs pdfplumber — 상세 비교
- PDF를 Markdown으로 변환 — Markdown 변환 가이드