읽기 순서와 XY-cut — 다단 PDF를 자연스러운 순서로 추출
다단 PDF — 학술 논문, 교과서, 잡지 기사, 정책 브리핑 — 는 대부분의 추출 도구를 무너뜨립니다. 단순히 위에서 아래로 읽는 방식은 1단의 한 단어, 2단의 한 단어, 다시 1단으로 돌아가며 읽어, accompaally(1단의 "accompa"와 2단의 "ally"가 붙은 결과) 같은 깨진 출력을 만들어 냅니다.
PDF Oxide는 XY-cut 알고리즘으로 단을 감지하고 자연스러운 읽기 순서를 자동으로 만듭니다. v0.3.34 부터는 드문 레이아웃(저작권 페이지, 표지)에서의 오탐을 방지하고, 본문 안에 표가 들어간 혼합 레이아웃도 정확하게 처리합니다.
빠른 예제
추출은 기본으로 단 인식 모드입니다. 별도의 플래그가 필요 없습니다.
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("academic-paper.pdf")
text = doc.extract_text(0)
# 각 단 내부에서 위에서 아래로 읽으며, 단을 교차해서 읽지 않습니다.
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));
XY-cut이 하는 일
XY-cut 알고리즘은 흰 여백을 따라 수직과 수평 자르기를 번갈아 하며, 페이지를 사각형 영역으로 재귀적으로 분할합니다.
- 모든 문자를 X축에 투영합니다. 높고 넓은 세로 간격(단 사이 여백)이 발견되면 그 X 좌표에서 페이지를 두 영역으로 나눕니다.
- 각 영역 안에서 Y축에 투영하고, 가로 여백(문단 구분, 섹션 경계)에서 분할합니다.
- 리프 영역에 뚜렷한 여백이 남지 않을 때까지 재귀합니다. 이것이 최소 블록입니다.
- 블록을 위에서 아래, 왼쪽에서 오른쪽 순으로 직렬화합니다.
사람이 읽는 방식과 일치합니다. 1단을 위에서 아래, 이어서 2단을 위에서 아래, 마지막으로 페이지 전폭의 푸터가 있으면 그것을 읽습니다.
XY-cut이 동작하는 시점
extract_text가 다단 레이아웃을 감지하면 XY-cut이 자동 실행됩니다. 다음과 같은 경우에는 건너뜁니다.
- 한 단짜리 페이지(세로 여백이 없으므로 기본 행 단위 정렬 사용)
- 단으로 추정되는 영역당 텍스트 span이 약 10개 미만인 드문 레이아웃 — 표지나 저작권 페이지에서 두 개의 X 중심 피크가 실제 단이 아니라 잡음인 경우(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 %를 넘는 span은 투영 밀도 계산에서 제외합니다 — 탭으로 벌어진 행이 여백을 가리지 않습니다.
G,T같은 단일 문자 span(표 셀 값)은 투영에서 제외해 여백 너머로 흩어지지 않습니다.- 커버리지는 bbox의 원시 너비 대신 문자 수 추정치를 사용하므로, 탭 벌림 행이 빽빽한 본문처럼 보이지 않습니다.
드문 레이아웃에서의 오탐
저작권 페이지, 표지, 판권지는 "단"당 7~10개 정도의 span만으로 두 개의 X 중심 피크를 만들 수 있습니다. 더 이상 다단으로 취급하지 않으므로, 같은 줄의 양쪽 X 위치에 있는 반쪽 문장들을 XY-cut이 분리하지 않습니다.
단별 구조화 접근
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#는 (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})");
각 단어 / 라인은 바운딩 박스를 가지고 있어, 단별로 묶어 직접 재정렬할 수 있습니다(예: 아랍어 레이아웃에서는 오른쪽 단부터 읽는 등).
다단 페이지 직접 감지
추출 전에 페이지가 다단인지에 따라 분기하려면:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("mixed.pdf")
for i in range(doc.page_count()):
words = doc.extract_words(i)
# 휴리스틱: X 중심 클러스터 개수
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)")
실서비스에서는 extract_text를 사용하고, XY-cut과 드문 레이아웃 가드의 조합이 판단하도록 맡기는 것이 좋습니다.
옵트아웃 또는 커스텀 순서
위치 순으로 정렬된 원시 span이 필요하다면(예: 자체 레이아웃 엔진용) extract_chars나 extract_words를 사용하세요. 바운딩 박스가 포함된 레코드를 반환하므로 원하는 정렬을 직접 적용할 수 있습니다.
Python
chars = doc.extract_chars(0)
# 위에서 아래로, 그다음 왼쪽에서 오른쪽으로 — 단을 무시
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()));