Skip to content

Lesereihenfolge & XY-Cut — mehrspaltige PDFs in natürlicher Reihenfolge extrahieren

Mehrspaltige PDFs — wissenschaftliche Artikel, Lehrbücher, Zeitschriftenbeiträge, Politikpapiere — bringen die meisten Extraktionswerkzeuge durcheinander. Ein naives Lesen von oben nach unten entnimmt abwechselnd ein Wort aus Spalte 1 und eines aus Spalte 2 und erzeugt kauderwelsch wie accompaally ("accompa" aus Spalte 1 direkt gefolgt von "ally" aus Spalte 2).

PDF Oxide verwendet einen XY-Cut-Algorithmus, um Spalten zu erkennen und die natürliche Lesereihenfolge automatisch herzustellen. Ab v0.3.34 schützt der Algorithmus außerdem vor Falschtreffern bei dünn besetzten Seiten (Impressums- und Titelseiten) und verarbeitet Mischlayouts korrekt, bei denen eine Tabelle im Fließtext eingebettet ist.

Schnellbeispiel

Die Extraktion erkennt Spalten standardmäßig — kein Flag erforderlich:

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)

Funktionsweise des XY-Cut

Der XY-Cut-Algorithmus zerlegt eine Seite rekursiv in rechteckige Bereiche, indem er abwechselnd vertikale und horizontale Schnitte entlang von Weißraumguttern vornimmt:

  1. Alle Zeichen werden auf die X-Achse projiziert. Zeigt sich ein hoher, breiter vertikaler Leerraum (der Spaltensteg), wird die Seite an dieser X-Koordinate in zwei Bereiche geteilt.
  2. Innerhalb jedes Bereichs wird auf die Y-Achse projiziert und entlang horizontaler Guttern (Absatzabstände, Abschnittsgrenzen) geschnitten.
  3. Die Rekursion setzt sich fort, bis jeder Blattbereich keine starken Guttern mehr aufweist — das sind die atomaren Blöcke.
  4. Die Blöcke werden von oben nach unten und von links nach rechts serialisiert.

Das entspricht der menschlichen Leseweise: Spalte 1 von oben nach unten, dann Spalte 2 von oben nach unten, schließlich ein ggf. vorhandener Fußbereich über die volle Seitenbreite.

Wann XY-Cut aktiviert wird

XY-Cut läuft automatisch, wenn extract_text ein mehrspaltiges Layout erkennt. Es wird übersprungen bei:

  • Einspaltige Seiten (kein vertikaler Steg gefunden, daher wird die standardmäßige zeilenbasierte Sortierung verwendet)
  • Dünn besetzte Seiten mit weniger als ~10 Textspans pro scheinbarer Spalte — das sind in der Regel Titel- oder Copyrightseiten, bei denen zwei X-Zentrum-Peaks ein Artefakt und keine echten Spalten sind (behoben in v0.3.34)

Für den Standardfall ist keine Konfiguration erforderlich. Wer einen bestimmten Modus erzwingen möchte, lese weiter unter „Deaktivierung".

Was v0.3.34 behoben hat

Verschränkte Mehrspaltenausgabe bei nicht-getaggten PDFs

Bei nicht-getaggten mehrspaltigen PDFs (wissenschaftliche Lehrbücher, Genetik-Referenzwerke) wendete extract_text zuvor XY-Cut innerhalb von extract_spans() an und sortierte das Ergebnis anschließend in extract_text_with_options mit einer zeilenbasierten Sortierung neu, wodurch die Spaltenstruktur zerstört wurde. Resultat: kauderwelschähnliche Fragmente wie accompaally.

Behebung: Die zeilenbasierte Neusortierung wird für Seiten, die tatsächlich mehrspaltig sind, nun übersprungen. Geprüft mit den Lehrbüchern Hartwell Genetics, Murphy ML und Kandel Neural Science.

Seiten mit Tabelle im Fließtext

Bei Mischlayout-Seiten (Tabelle im laufenden Text) konnten durch Tabulatoren aufgefüllte Tabellenzeilen den Spaltensteg ausfüllen und den Spaltendetektor täuschen. Korrekturen:

  • Breite Spans (>55 % der Bereichsbreite) werden bei der Projektionsdichte nicht berücksichtigt — tabulatorgefüllte Zeilen verdecken den Steg nicht mehr.
  • Einzelzeichen-Spans (Tabellenzellinhalte wie G, T) werden aus der Projektion ausgeschlossen, damit sie sich nicht über den Steg verteilen.
  • Die Abdeckung wird anhand einer geschätzten Zeichenanzahl statt der rohen Bounding-Box-Breite berechnet, sodass tabulatorgefüllte Zeilen nicht mehr als dichter Fließtext erscheinen.

Falschpositive bei dünn besetzten Layouts

Copyrightseiten, Titelseiten und Impressum können zwei X-Zentrum-Peaks erzeugen, obwohl jede „Spalte" nur 7–10 Spans enthält. Solche Seiten werden nun nicht mehr als mehrspaltig behandelt, sodass XY-Cut keine Sätze mehr zerschneidet, deren Hälften auf derselben Zeile an unterschiedlichen X-Positionen liegen.

Strukturierter Zugriff pro Spalte

Unterhalb von extract_text können Wörter oder Zeichendaten mit derselben Spaltenreihenfolge abgerufen werden:

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)

Jedes Wort und jede Zeile enthält seine Bounding Box, sodass nach Spalten gruppiert und nach einer eigenen Richtlinie neu geordnet werden kann (z. B. zuerst die rechte Spalte für arabische Layouts).

Mehrspaltige Seiten manuell erkennen

Wer vor der Extraktion prüfen möchte, ob eine Seite mehrspaltig ist:

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

Für den produktiven Einsatz empfiehlt sich extract_text, damit XY-Cut und der Schutz vor dünn besetzten Seiten die Entscheidung übernehmen.

Deaktivierung oder eigene Sortierung

Wer rohe, positionsgeordnete Spans benötigt (z. B. für eine eigene Layout-Engine), verwendet extract_chars oder extract_words — diese liefern Datensätze mit Bounding Boxes und erlauben beliebige Sortierungen:

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)

Verwandte Seiten