Skip to content

Leseordnung & XY-Cut — Mehrspaltige PDFs in natürlicher Reihenfolge extrahieren

Mehrspaltige PDFs — wissenschaftliche Arbeiten, Lehrbücher, Magazinartikel, politische Kurzdossiers — bringen die meisten Extraktionswerkzeuge aus dem Tritt. Ein naiver Top-to-Bottom-Durchlauf zieht ein Wort aus Spalte 1, dann eins aus Spalte 2, dann wieder aus Spalte 1 — und produziert Kauderwelsch wie accompaally ("accompa" aus Spalte 1, verklebt mit "ally" aus Spalte 2).

PDF Oxide setzt einen XY-Cut-Algorithmus ein, um Spalten automatisch zu erkennen und eine natürliche Leseordnung zu erzeugen. Seit v0.3.34 schützt er zusätzlich vor falsch-positiven Treffern auf dünn besetzten Layouts (Impressum-, Titelseiten) und verarbeitet gemischte Layouts korrekt, in denen eine Tabelle inmitten eines Fließtexts steht.

Kurzes Beispiel

Die Extraktion ist standardmäßig spaltenbewusst — ohne zusätzlichen Schalter:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("academic-paper.pdf")
text = doc.extract_text(0)
# Spalten werden von oben nach unten innerhalb jeder Spalte gelesen, nicht verschränkt.

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));

Was XY-Cut leistet

Der XY-Cut-Algorithmus zerlegt eine Seite rekursiv in rechteckige Regionen, indem er abwechselnd vertikale und horizontale Schnitte entlang von Weißraum-Fugen anlegt:

  1. Alle Zeichen werden auf die X-Achse projiziert. Zeigt sich eine hohe, breite vertikale Lücke (die Spaltenfuge), wird die Seite an dieser X-Koordinate in zwei Regionen geteilt.
  2. Innerhalb jeder Region wird auf die Y-Achse projiziert und an horizontalen Fugen (Absatzumbrüche, Abschnittsgrenzen) geteilt.
  3. Die Rekursion endet, wenn eine Blattregion keine deutliche Fuge mehr enthält — das sind die atomaren Blöcke.
  4. Blöcke werden in der Reihenfolge oben nach unten, links nach rechts serialisiert.

Das entspricht der Lesegewohnheit eines Menschen: Spalte 1 von oben nach unten, dann Spalte 2 von oben nach unten, dann eine etwaige seitenbreite Fußzeile.

Wann XY-Cut greift

XY-Cut läuft automatisch, sobald extract_text ein mehrspaltiges Layout erkennt. Übersprungen wird er bei:

  • Einspaltigen Seiten (keine vertikale Fuge gefunden; es greift die standardmäßige zeilenbewusste Sortierung)
  • Dünn besetzten Seiten mit weniger als etwa 10 Textspannen pro vermuteter Spalte — typischerweise Titel- oder Impressumsseiten, bei denen zwei X-Zentren-Peaks ein Artefakt und keine echten Spalten sind (behoben in v0.3.34)

Für den Normalfall ist keine Konfiguration nötig. Wer einen der beiden Modi erzwingen möchte, findet weiter unten den Abschnitt „Opt-out".

Was v0.3.34 behoben hat

Verschränkte Mehrspaltenausgabe bei nicht getaggten PDFs

Bei nicht getaggten mehrspaltigen PDFs (wissenschaftlichen Lehrbüchern, Genetik-Nachschlagewerken) wendete extract_text den XY-Cut zuvor innerhalb von extract_spans() an und sortierte das Ergebnis in extract_text_with_options anschließend mit einer zeilenbewussten Sortierung neu — wodurch die Spaltenstruktur wieder zerschlagen wurde. Ergebnis: Fragmente wie accompaally.

Fix: Die zeilenbewusste Nachsortierung wird bei Seiten, die tatsächlich mehrspaltig sind, nun übersprungen. Verifiziert auf Hartwell Genetics, Murphy ML und Kandel Neural Science.

Seiten mit Tabelle im Fließtext

Gemischte Layouts (eine Tabelle in laufendem Fließtext) konnten den Spalten-Detektor täuschen, weil tabulatorgefüllte Tabellenzeilen die Spaltenfuge ausfüllten. Fix:

  • Breite Spannen (über 55 % der Regionsbreite) werden aus der Projektionsdichte ausgeschlossen — tabulatorgefüllte Zeilen verdecken die Fuge nicht mehr.
  • Ein-Zeichen-Spannen (Tabellenzellen-Werte wie G, T) werden aus der Projektion ausgeschlossen, damit sie sich nicht über die Fuge verstreuen.
  • Die Abdeckung wird anhand einer Zeichenzahl-Schätzung statt der rohen Bbox-Breite berechnet, sodass tabulatorgefüllte Zeilen nicht mehr als dichter Fließtext durchgehen.

Falsch-positive Treffer bei dünnen Layouts

Impressum-, Titel- und Kolophonseiten können zwei X-Zentren-Peaks erzeugen — bei nur 7–10 Spannen pro „Spalte". Sie werden nicht mehr als mehrspaltig behandelt, sodass der XY-Cut keine Sätze mehr auseinanderreißt, deren Hälften an unterschiedlichen X-Positionen derselben Zeile sitzen.

Strukturierter Zugriff pro Spalte

Eine Ebene unter extract_text können Sie Wörter oder zeichenweise Daten mit derselben Spaltenordnung abrufen:

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# liefern Zeilen der Form (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})");

Jedes Wort bzw. jede Zeile trägt ihre Bounding Box mit sich, sodass Sie nach Spalten gruppieren und selbst neu sortieren können, falls Sie eine eigene Regel brauchen (z. B. für arabische Layouts zuerst die rechte Spalte lesen).

Mehrspaltigkeit manuell erkennen

Wer vor der Extraktion verzweigen will, 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)
    # Heuristik: unterschiedliche X-Zentren-Cluster
    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)")

Im produktiven Einsatz ist extract_text vorzuziehen — lassen Sie den XY-Cut samt Schutz gegen dünne Layouts die Entscheidung treffen.

Opt-out oder eigene Reihenfolge

Wer Rohspannen in Positionsreihenfolge braucht (etwa für eine eigene Layout-Engine), verwendet extract_chars oder extract_words — diese liefern Datensätze mit Bounding Boxes, auf die eine eigene Sortierung angewendet werden kann:

Python

chars = doc.extract_chars(0)
# Oben nach unten, dann links nach rechts — Spalten werden ignoriert
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()));

Verwandte Seiten