Skip to content

읽기 순서와 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 알고리즘은 빈 여백(단 간격)을 따라 수직·수평 컷을 번갈아 적용하며 페이지를 직사각형 영역으로 재귀적으로 분할합니다.

  1. 모든 문자를 X축에 투영합니다. 높고 넓은 수직 공백(단 간격)이 발견되면 해당 X 좌표에서 페이지를 두 영역으로 분할합니다.
  2. 각 영역 내에서 Y축에 투영하고 수평 공백(단락 구분, 섹션 경계)을 따라 분할합니다.
  3. 각 리프 영역에 뚜렷한 공백이 없어질 때까지 재귀합니다 — 이것이 원자 블록입니다.
  4. 블록을 위에서 아래, 왼쪽에서 오른쪽 순으로 직렬화합니다.

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

관련 페이지