Skip to content

읽기 순서와 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 알고리즘은 흰 여백을 따라 수직과 수평 자르기를 번갈아 하며, 페이지를 사각형 영역으로 재귀적으로 분할합니다.

  1. 모든 문자를 X축에 투영합니다. 높고 넓은 세로 간격(단 사이 여백)이 발견되면 그 X 좌표에서 페이지를 두 영역으로 나눕니다.
  2. 각 영역 안에서 Y축에 투영하고, 가로 여백(문단 구분, 섹션 경계)에서 분할합니다.
  3. 리프 영역에 뚜렷한 여백이 남지 않을 때까지 재귀합니다. 이것이 최소 블록입니다.
  4. 블록을 위에서 아래, 왼쪽에서 오른쪽 순으로 직렬화합니다.

사람이 읽는 방식과 일치합니다. 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_charsextract_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()));

관련 페이지