읽기 순서와 XY-cut — 다단 PDF를 자연스러운 순서로 추출하기
학술 논문, 교재, 잡지 기사, 정책 문서 같은 다단 PDF는 대부분의 추출 도구를 곤란하게 만듭니다. 단순히 위에서 아래로 읽으면 1단과 2단의 단어가 번갈아 추출되어 accompaally("accompa"는 1단, "ally"는 2단)와 같은 뒤죽박죽 출력이 나옵니다.
PDF Oxide는 XY-cut 알고리즘으로 단을 감지하고 자연스러운 읽기 순서를 자동으로 생성합니다. v0.3.34부터는 희소 레이아웃 오탐(저작권 페이지, 표지)을 방지하고, 본문 안에 표가 들어 있는 혼합 레이아웃도 올바르게 처리합니다.
빠른 예제
추출은 기본적으로 단을 인식합니다 — 별도 플래그가 필요 없습니다.
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("academic-paper.pdf")
text = doc.extract_text(0)
# Columns are read top-to-bottom within each column, not interleaved.
Rust
use pdf_oxide::PdfDocument;
let mut doc = PdfDocument::open("academic-paper.pdf")?;
let text = doc.extract_text(0)?;
JavaScript / TypeScript (Node)
const { PdfDocument } = require("pdf-oxide");
const doc = new PdfDocument("academic-paper.pdf");
const text = doc.extractText(0);
doc.close();
JavaScript (WASM)
import { WasmPdfDocument } from "pdf-oxide-wasm";
const doc = new WasmPdfDocument(bytes);
console.log(doc.extractText(0));
doc.free();
Go
doc, _ := pdfoxide.Open("academic-paper.pdf")
defer doc.Close()
text, _ := doc.ExtractText(0)
fmt.Println(text)
C#
using PdfOxide;
using var doc = PdfDocument.Open("academic-paper.pdf");
Console.WriteLine(doc.ExtractText(0));
Java
import fyi.oxide.pdf.PdfDocument;
import java.nio.file.Path;
try (PdfDocument doc = PdfDocument.open(Path.of("academic-paper.pdf"))) {
String text = doc.extractText(0);
}
Kotlin
import fyi.oxide.pdf.PdfDocument
import java.nio.file.Path
PdfDocument.open(Path.of("academic-paper.pdf")).use { doc ->
val text = doc.extractText(0)
}
Scala
import fyi.oxide.pdf.PdfDocument
import scala.util.Using
Using.resource(PdfDocument.open("academic-paper.pdf")) { doc =>
val text = doc.extractText(0)
}
Clojure
(require '[pdf-oxide.core :as pdf])
(with-open [doc (pdf/open "academic-paper.pdf")]
(pdf/extract-text doc 0))
Ruby
require 'pdf_oxide'
PdfOxide::PdfDocument.open('academic-paper.pdf') do |doc|
text = doc.extract_text(0)
end
PHP
use PdfOxide\PdfDocument;
$doc = PdfDocument::open('academic-paper.pdf');
$text = $doc->extractText(0);
$doc->close();
C++
#include <pdf_oxide/pdf_oxide.hpp>
auto doc = pdf_oxide::Document::open("academic-paper.pdf");
auto text = doc.extract_text(0);
Swift
import PdfOxide
let doc = try Document.open("academic-paper.pdf")
let text = try doc.extractText(0)
Dart
import 'package:pdf_oxide/pdf_oxide.dart';
final doc = PdfDocument.open('academic-paper.pdf');
final text = doc.extractText(0);
doc.close();
R
library(pdfoxide)
doc <- pdf_open("academic-paper.pdf")
text <- pdf_extract_text(doc, 0)
Julia
using PdfOxide
doc = open_document("academic-paper.pdf")
text = extract_text(doc, 0)
Zig
const pdf_oxide = @import("pdf_oxide");
const a = std.heap.page_allocator;
var doc = try pdf_oxide.Document.open("academic-paper.pdf");
const text = try doc.extractText(a, 0);
Objective-C
#import "POXPdfOxide.h"
NSError *err = nil;
POXDocument *doc = [POXDocument openPath:@"academic-paper.pdf" error:&err];
NSString *text = [doc extractText:0 error:&err];
Elixir
{:ok, doc} = PdfOxide.open("academic-paper.pdf")
{:ok, text} = PdfOxide.extract_text(doc, 0)
XY-cut의 동작 원리
XY-cut 알고리즘은 빈 여백(단 간격)을 따라 수직·수평 컷을 번갈아 적용하며 페이지를 직사각형 영역으로 재귀적으로 분할합니다.
- 모든 문자를 X축에 투영합니다. 높고 넓은 수직 공백(단 간격)이 발견되면 해당 X 좌표에서 페이지를 두 영역으로 분할합니다.
- 각 영역 내에서 Y축에 투영하고 수평 공백(단락 구분, 섹션 경계)을 따라 분할합니다.
- 각 리프 영역에 뚜렷한 공백이 없어질 때까지 재귀합니다 — 이것이 원자 블록입니다.
- 블록을 위에서 아래, 왼쪽에서 오른쪽 순으로 직렬화합니다.
이는 사람이 읽는 방식과 일치합니다: 1단을 위에서 아래로 읽고, 2단을 위에서 아래로 읽은 뒤, 전체 너비의 하단 내용을 읽습니다.
XY-cut 활성화 조건
XY-cut은 extract_text가 다단 레이아웃을 감지하면 자동으로 실행됩니다. 다음 경우에는 건너뜁니다:
- 단 레이아웃 페이지 (수직 공백이 없으므로 기본 행 인식 정렬 사용)
- 추정 단별 텍스트 스팬이 약 10개 미만인 희소 페이지 — 보통 표지나 저작권 페이지로, X 중심 피크 2개는 실제 단이 아닌 아티팩트입니다 (v0.3.34에서 수정)
일반적인 경우에는 별도 설정이 필요 없습니다. 특정 모드를 강제하고 싶다면 아래의 “비활성화” 섹션을 참조하세요.
v0.3.34에서 수정된 내용
태그 없는 PDF에서 다단 출력 교차 문제
태그 없는 다단 PDF(학술 교재, 유전학 참고서 등)에서 extract_text는 이전에 extract_spans() 내부에서 XY-cut을 적용한 뒤, extract_text_with_options에서 행 인식 정렬로 결과를 재정렬하여 단 구조를 무너뜨렸습니다. 그 결과 accompaally 같은 뒤섞인 텍스트 조각이 출력되었습니다.
수정: 실제로 다단인 페이지에서는 행 인식 재정렬을 건너뜁니다. Hartwell Genetics, Murphy ML, Kandel Neural Science 교재에서 검증 완료.
본문 내 표가 있는 페이지
본문 안에 표가 내장된 혼합 레이아웃 페이지에서는 탭으로 확장된 표 행이 단 간격을 채워 단 감지기를 혼란시켰습니다. 수정 사항:
- 영역 너비의 55% 이상인 넓은 스팬은 투영 밀도 계산에서 제외됩니다 — 탭 채움 행이 더 이상 단 간격을 가리지 않습니다.
- 단일 문자 스팬(표 셀 값
G,T등)은 투영에서 제외되어 단 간격에 흩어지지 않습니다. - 커버리지 계산에 원시 바운딩 박스 너비 대신 문자 수 추정값을 사용하므로 탭 채움 행이 밀도 있는 본문으로 오인되지 않습니다.
희소 레이아웃 오탐
저작권 페이지, 표지, 판권 페이지는 "단"당 스팬이 7~10개에 불과해도 X 중심 피크 2개를 생성할 수 있습니다. 이제는 이런 경우 다단으로 취급하지 않으므로, XY-cut이 같은 행의 서로 다른 X 위치에 있는 문장을 잘못 분리하지 않습니다.
단별 구조화 접근
extract_text보다 낮은 수준에서 동일한 단 순서를 유지하면서 단어나 문자 수준 데이터를 가져올 수 있습니다:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("paper.pdf")
for w in doc.extract_words(0):
print(f"{w.text} ({w.x0:.0f},{w.y0:.0f})")
Rust
let mut doc = PdfDocument::open("paper.pdf")?;
for w in doc.extract_words(0)? {
println!("{} ({:.0},{:.0})", w.text, w.x0, w.y0);
}
Go
doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()
words, _ := doc.ExtractWords(0)
for _, w := range words {
fmt.Printf("%s (%.0f,%.0f)\n", w.Text, w.X0, w.Y0)
}
C#
using var doc = PdfDocument.Open("paper.pdf");
// Node/C# return rows of (text, x, y, w, h):
var lines = doc.ExtractTextLines(0);
foreach (var (text, x, y, w, h) in lines)
Console.WriteLine($"{text} ({x:F0},{y:F0})");
Java
try (PdfDocument doc = PdfDocument.open(Path.of("paper.pdf"))) {
for (TextWord w : doc.page(0).words()) {
System.out.printf("%s (%.0f,%.0f)%n", w.text(), w.bbox().x0(), w.bbox().y0());
}
}
Kotlin
PdfDocument.open(Path.of("paper.pdf")).use { doc ->
for (w in doc.page(0).words()) {
println("${w.text()} (${w.bbox().x0()},${w.bbox().y0()})")
}
}
Scala
Using.resource(PdfDocument.open("paper.pdf")) { doc =>
doc.page(0).wordsSeq.foreach { w =>
println(f"${w.text} (${w.bbox.x0}%.0f,${w.bbox.y0}%.0f)")
}
}
Clojure
(with-open [doc (pdf/open "paper.pdf")]
(doseq [w (pdf/words (pdf/page doc 0))]
(printf "%s (%.0f,%.0f)%n" (.text w) (.. w bbox x0) (.. w bbox y0))))
C++
auto doc = pdf_oxide::Document::open("paper.pdf");
for (const auto& w : doc.extract_words(0)) {
std::printf("%s (%.0f,%.0f)\n", w.text.c_str(), w.bbox.x, w.bbox.y);
}
Swift
let doc = try Document.open("paper.pdf")
for w in try doc.extractWords(0) {
print("\(w.text) (\(w.bbox.x),\(w.bbox.y))")
}
Dart
final doc = PdfDocument.open('paper.pdf');
for (final w in doc.extractWords(0)) {
print('${w.text} (${w.bbox.x},${w.bbox.y})');
}
doc.close();
R
doc <- pdf_open("paper.pdf")
words <- pdf_extract_words(doc, 0)
for (w in words) {
cat(sprintf("%s (%.0f,%.0f)\n", w$text, w$bbox$x, w$bbox$y))
}
Julia
doc = open_document("paper.pdf")
for w in extract_words(doc, 0)
println("$(w.text) ($(w.bbox.x),$(w.bbox.y))")
end
Zig
var doc = try pdf_oxide.Document.open("paper.pdf");
const words = try doc.extractWords(a, 0);
defer pdf_oxide.Document.freeWords(a, words);
for (words) |w| {
std.debug.print("{s} ({d:.0},{d:.0})\n", .{ w.text, w.bbox.x, w.bbox.y });
}
Objective-C
POXDocument *doc = [POXDocument openPath:@"paper.pdf" error:&err];
for (POXWord *w in [doc extractWords:0 error:&err]) {
NSLog(@"%@ (%.0f,%.0f)", w.text, w.bbox.x, w.bbox.y);
}
Elixir
{:ok, doc} = PdfOxide.open("paper.pdf")
{:ok, words} = PdfOxide.extract_words(doc, 0)
Enum.each(words, fn w ->
IO.puts("#{w.text} (#{w.bbox.x},#{w.bbox.y})")
end)
각 단어와 줄은 바운딩 박스를 포함하므로 단별로 그룹화해 자체 정책(예: 아랍어 레이아웃에서 오른쪽 단 우선 읽기)을 적용할 수 있습니다.
다단 페이지 수동 감지
추출 전에 페이지가 다단인지 판별하고 싶다면:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("mixed.pdf")
for i in range(doc.page_count()):
words = doc.extract_words(i)
# Heuristic: distinct X-center clusters
x_centers = {round((w.x0 + w.x1) / 2 / 50) * 50 for w in words}
if len(x_centers) >= 2:
print(f"Page {i}: likely multi-column ({len(x_centers)} X-centers)")
Java
try (PdfDocument doc = PdfDocument.open(Path.of("mixed.pdf"))) {
for (int i = 0; i < doc.pageCount(); i++) {
Set<Long> xCenters = new HashSet<>();
for (TextWord w : doc.page(i).words()) {
double cx = w.bbox().x0() + w.bbox().width() / 2;
xCenters.add(Math.round(cx / 50) * 50L);
}
if (xCenters.size() >= 2)
System.out.printf("Page %d: likely multi-column (%d X-centers)%n", i, xCenters.size());
}
}
Kotlin
PdfDocument.open(Path.of("mixed.pdf")).use { doc ->
for (i in 0 until doc.pageCount()) {
val xCenters = doc.page(i).words().map {
(Math.round((it.bbox().x0() + it.bbox().width() / 2) / 50) * 50)
}.toSet()
if (xCenters.size >= 2)
println("Page $i: likely multi-column (${xCenters.size} X-centers)")
}
}
Scala
Using.resource(PdfDocument.open("mixed.pdf")) { doc =>
for (i <- 0 until doc.pageCount()) {
val xCenters = doc.page(i).wordsSeq.map { w =>
math.round((w.bbox.x0 + w.bbox.width / 2) / 50) * 50
}.toSet
if (xCenters.size >= 2)
println(s"Page $i: likely multi-column (${xCenters.size} X-centers)")
}
}
Clojure
(with-open [doc (pdf/open "mixed.pdf")]
(doseq [i (range (pdf/page-count doc))]
(let [xs (set (map #(* 50 (Math/round (/ (+ (.. % bbox x0) (/ (.. % bbox width) 2)) 50.0)))
(pdf/words (pdf/page doc i))))]
(when (>= (count xs) 2)
(printf "Page %d: likely multi-column (%d X-centers)%n" i (count xs))))))
C++
auto doc = pdf_oxide::Document::open("mixed.pdf");
for (int i = 0; i < doc.page_count(); ++i) {
std::set<long> x_centers;
for (const auto& w : doc.extract_words(i))
x_centers.insert(std::lround((w.bbox.x + w.bbox.width / 2) / 50) * 50);
if (x_centers.size() >= 2)
std::printf("Page %d: likely multi-column (%zu X-centers)\n", i, x_centers.size());
}
Swift
let doc = try Document.open("mixed.pdf")
for i in 0..<(try doc.pageCount()) {
let xCenters = Set(try doc.extractWords(i).map {
(($0.bbox.x + $0.bbox.width / 2) / 50).rounded() * 50
})
if xCenters.count >= 2 {
print("Page \(i): likely multi-column (\(xCenters.count) X-centers)")
}
}
Dart
final doc = PdfDocument.open('mixed.pdf');
for (var i = 0; i < doc.pageCount; i++) {
final xCenters = doc.extractWords(i)
.map((w) => ((w.bbox.x + w.bbox.width / 2) / 50).round() * 50)
.toSet();
if (xCenters.length >= 2) {
print('Page $i: likely multi-column (${xCenters.length} X-centers)');
}
}
doc.close();
R
doc <- pdf_open("mixed.pdf")
for (i in 0:(pdf_page_count(doc) - 1)) {
words <- pdf_extract_words(doc, i)
x_centers <- unique(sapply(words, function(w)
round((w$bbox$x + w$bbox$width / 2) / 50) * 50))
if (length(x_centers) >= 2)
cat(sprintf("Page %d: likely multi-column (%d X-centers)\n", i, length(x_centers)))
}
Julia
doc = open_document("mixed.pdf")
for i in 0:(page_count(doc) - 1)
x_centers = Set(round(Int, (w.bbox.x + w.bbox.width / 2) / 50) * 50
for w in extract_words(doc, i))
if length(x_centers) >= 2
println("Page $i: likely multi-column ($(length(x_centers)) X-centers)")
end
end
Zig
var doc = try pdf_oxide.Document.open("mixed.pdf");
const n = try doc.pageCount();
var i: i32 = 0;
while (i < n) : (i += 1) {
const words = try doc.extractWords(a, i);
defer pdf_oxide.Document.freeWords(a, words);
var centers = std.AutoHashMap(i64, void).init(a);
defer centers.deinit();
for (words) |w| {
const c: i64 = @intFromFloat(@round((w.bbox.x + w.bbox.width / 2) / 50) * 50);
try centers.put(c, {});
}
if (centers.count() >= 2)
std.debug.print("Page {d}: likely multi-column ({d} X-centers)\n", .{ i, centers.count() });
}
Objective-C
POXDocument *doc = [POXDocument openPath:@"mixed.pdf" error:&err];
for (NSInteger i = 0; i < [doc pageCountError:&err]; i++) {
NSMutableSet<NSNumber*> *xCenters = [NSMutableSet set];
for (POXWord *w in [doc extractWords:i error:&err]) {
long c = lround((w.bbox.x + w.bbox.width / 2) / 50) * 50;
[xCenters addObject:@(c)];
}
if (xCenters.count >= 2)
NSLog(@"Page %ld: likely multi-column (%lu X-centers)", (long)i, (unsigned long)xCenters.count);
}
Elixir
{:ok, doc} = PdfOxide.open("mixed.pdf")
{:ok, n} = PdfOxide.page_count(doc)
for i <- 0..(n - 1) do
{:ok, words} = PdfOxide.extract_words(doc, i)
x_centers = words
|> Enum.map(fn w -> round((w.bbox.x + w.bbox.width / 2) / 50) * 50 end)
|> Enum.uniq()
if length(x_centers) >= 2 do
IO.puts("Page #{i}: likely multi-column (#{length(x_centers)} X-centers)")
end
end
실제 운영 환경에서는 extract_text를 사용하고 라이브러리의 XY-cut과 희소 레이아웃 보호 기능에 판단을 맡기는 것이 좋습니다.
비활성화 또는 사용자 정의 순서
원시 위치 순 스팬(예: 커스텀 레이아웃 엔진용)이 필요하다면 extract_chars나 extract_words를 사용하세요 — 바운딩 박스가 포함된 레코드를 반환하므로 자체 정렬을 적용할 수 있습니다:
Python
chars = doc.extract_chars(0)
# Top-to-bottom, then left-to-right — ignores columns
chars_sorted = sorted(chars, key=lambda c: (-c.y, c.x))
Rust
let mut chars = doc.extract_chars(0)?;
chars.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap()
.then(a.x.partial_cmp(&b.x).unwrap()));
Java
List<TextChar> chars = new ArrayList<>(doc.page(0).chars());
// Top-to-bottom, then left-to-right — ignores columns
chars.sort(Comparator
.comparingDouble((TextChar c) -> c.bbox().y0()).reversed()
.thenComparingDouble(c -> c.bbox().x0()));
Kotlin
val chars = doc.page(0).chars()
.sortedWith(compareByDescending<TextChar> { it.bbox().y0() }
.thenBy { it.bbox().x0() })
Scala
val chars = doc.page(0).charsSeq
.sortBy(c => (-c.bbox.y0, c.bbox.x0))
Clojure
(def chars
(sort-by (juxt #(- (.. % bbox y0)) #(.. % bbox x0))
(pdf/chars (pdf/page doc 0))))
C++
auto chars = doc.extract_chars(0);
// Top-to-bottom, then left-to-right — ignores columns
std::sort(chars.begin(), chars.end(), [](const auto& a, const auto& b) {
return a.bbox.y != b.bbox.y ? a.bbox.y > b.bbox.y : a.bbox.x < b.bbox.x;
});
Swift
let chars = try doc.extractChars(0).sorted {
$0.bbox.y != $1.bbox.y ? $0.bbox.y > $1.bbox.y : $0.bbox.x < $1.bbox.x
}
Dart
final chars = doc.extractChars(0)
..sort((a, b) => a.bbox.y != b.bbox.y
? b.bbox.y.compareTo(a.bbox.y)
: a.bbox.x.compareTo(b.bbox.x));
R
chars <- pdf_extract_chars(doc, 0)
# Top-to-bottom, then left-to-right — ignores columns
chars <- chars[order(-sapply(chars, function(c) c$bbox$y),
sapply(chars, function(c) c$bbox$x))]
Julia
chars = extract_chars(doc, 0)
# Top-to-bottom, then left-to-right — ignores columns
sort!(chars, by = c -> (-c.bbox.y, c.bbox.x))
Zig
const chars = try doc.extractChars(a, 0);
defer pdf_oxide.Document.freeChars(a, chars);
std.mem.sort(pdf_oxide.Char, chars, {}, struct {
fn lt(_: void, x: pdf_oxide.Char, y: pdf_oxide.Char) bool {
return if (x.bbox.y != y.bbox.y) x.bbox.y > y.bbox.y else x.bbox.x < y.bbox.x;
}
}.lt);
Objective-C
NSArray<POXChar*> *chars = [doc extractChars:0 error:&err];
// Top-to-bottom, then left-to-right — ignores columns
chars = [chars sortedArrayUsingComparator:^NSComparisonResult(POXChar *a, POXChar *b) {
if (a.bbox.y != b.bbox.y) return a.bbox.y > b.bbox.y ? NSOrderedAscending : NSOrderedDescending;
return a.bbox.x < b.bbox.x ? NSOrderedAscending : NSOrderedDescending;
}];
Elixir
{:ok, chars} = PdfOxide.extract_chars(doc, 0)
# Top-to-bottom, then left-to-right — ignores columns
chars = Enum.sort_by(chars, fn c -> {-c.bbox.y, c.bbox.x} end)
관련 페이지
- 텍스트 추출 — 전체 추출 API
- 추출 프로필 — 문서 유형별 공백 감지 조정
- PDF에서 표 추출하기 — 구조화된 표 출력
- 변경 이력 — v0.3.34 다단 및 혼합 레이아웃 수정