PDFs sind für Menschen, nicht für Daten

Oder: Wie eine Drucksprache aus den 1990ern dein „einfach parsen“-Skript trollt.

G
Greg T · Engineering· 9 Min. Lesezeit·Sep 20, 2025
Read in English

Wir lieben PDFs. Sie sehen auf jedem Gerät gleich aus, drucken wunderschön in jeder Größe und sind das Nächste, was wir an digitalem Papier haben. Aber jedes Mal, wenn jemand in unserem Team sagt: „Lass uns einfach die Daten aus dem PDF extrahieren“, spüren wir, wie ein uralter PostScript-Dämon erwacht und flüstert: „Ich wurde geboren, um Pixel zu malen, nicht um deine Zeilen zu strukturieren.“

In diesem Artikel gehen wir darauf ein, warum PDFs großartig für die Präsentation und schrecklich für Daten sind. Wir werfen einen Blick in das Innere eines PDFs, bauen ein kleines Beispiel und versuchen dann (und scheitern, und versuchen es erneut), einen fiktiven Kontoauszug zu extrahieren. Am Ende hoffen wir, dass du aufhörst zu erwarten, dass sich PDFs wie CSVs im Trenchcoat verhalten.


Die falsche Abstammung

PDF ist nicht mit dem Wunsch aufgewachsen, eine API zu sein. Es wuchs mit dem Wunsch auf, ein Druckertreiber zu sein, der deinen Drucker nie treffen musste. Das PDF-Modell ist im Wesentlichen: eine Abfolge von Zeichenbefehlen.

Das bedeutet „Zeichne Text hier, in dieser Schriftart, in dieser Größe, dann verschiebe ein bisschen, dann zeichne mehr Text.“ Es bedeutet nicht „Dies ist eine Tabelle mit 5 Spalten und einer Kopfzeile.“ Es gibt kein natives Konzept von Zeilen, Spalten oder sogar Wörtern. Es gibt nur Glyphen, die an Koordinaten platziert werden.

Wenn man das im Hinterkopf behält, ergibt alles, was folgt... nun ja, keinen Sinn, aber es fühlt sich zumindest weniger bösartig an.


Eine 30-Sekunden-Tour durch ein PDF

In einem PDF findest du:

  • Objekte: nummerierte Blobs (Wörterbücher, Arrays, Streams), die Seiten, Schriftarten, Bilder usw. definieren.

  • Content-Streams: komprimierte Byte-Streams, die die Zeichenbefehle für jede Seite enthalten.

  • Text-Operatoren: Dinge wie BT (begin text), Tj/TJ (show text), Td/Tm (move the text matrix) usw.

  • Cross‑reference (xref):

Das Telefonbuch, das einem Reader sagt, wo jedes Objekt in der Datei lebt.

Du kannst ein einfaches PDF in einem Texteditor öffnen und die Augen zusammenkneifen. Teile werden lesbar sein; Teile sehen aus wie Rauschen, weil es komprimierte Streams sind. Das ist normal.

Ein Spielzeug-Content-Stream

Hier ist ein drastisch vereinfachter (und bereinigter) Schnipsel, wie du ihn nach dem Dekomprimieren eines Seiten-Content-Streams sehen könntest:

PDF Bank Statement Example

BT                      % Beginn Textobjekt
/F1 12 Tf               % Wähle Schriftart F1 mit 12 Punkten
72 720 Td               % Bewege zu Position (72, 720)
(2024-05-01) Tj         % Zeichne einfachen Text "2024-05-01"
0 -20 Td                % Bewege 20 Einheiten nach unten
(VEGA PARKING - REF: 827492) Tj        % Zeichne einfachen Text "VEGA PARKING - REF: 827492"
0 -20 Td                % Bewege 20 Einheiten nach unten
[(1) -50 (2) -50 (3) -50 (4)] TJ    % Zeichne "1234" mit Abständen
0 -20 Td                % Bewege 20 Einheiten nach unten
[(Amount:) -100 ($) -20 (1) -30 (2) -30 (3) -30 (.) -20 (4) -30 (5)] TJ
                        % Komplexes TJ: "Amount: $123.45" mit Kerning
0 -20 Td                % Bewege 20 Einheiten nach unten
(Regular text here) Tj  % Wieder einfacher Text
ET                      % Ende Textobjekt

Zwei wichtige Dinge sind gerade passiert:

  1. Tj und TJ zeichnen Glyphen, keine „Zeichen in einem Wort“. Manchmal kodiert die Schriftart Glyphen auf seltsame Weise (benutzerdefinierte Kodierungen, Subsets). Du kannst nicht davon ausgehen, dass Bytes auf Unicode abbilden.
  2. TJ nimmt ein Array von Strings und Zahlen entgegen. Die Zahlen passen Abstände an (Kerning). Leute benutzen es, damit Zahlen hübsch aussehen. Dein Extraktor muss nun die visuellen Positionen berechnen, um zu rekonstruieren, wo „1 2 3 4“ tatsächlich landen.

Warum dein Extraktor in den Wahnsinn getrieben wird

Gehen wir drei häufige Fallstricke durch, die „einfaches PDF“ in „Wochenende ruiniert“ verwandeln.

1) Text ist kein Text

Wenn eine Datei eine eingebettete Schriftart-Teilmenge (z. B. /F1+ABCDEE+Inter) verwendet, könnte der sichtbare Buchstabe A tatsächlich die Glyphen-ID 37 sein, die diese Schriftart auf A abbildet, aber die Glyphe 37 eines anderen PDFs könnte auf Ω abbilden. Wenn die Schriftart vergisst, eine /ToUnicode-Map einzufügen (oder lügt), liefert die Rohextraktion Müll. OCR wird dich nicht retten, wenn der Text bereits Vektorglyphen sind.

Symptom: Du siehst Tj-Strings wie \x12\x7F\x03 und dein Tool gibt fröhlich zurück.

Lösungsansatz: Heuristiken + Font-Decoding + beten, dass die /ToUnicode-Map existiert. Andernfalls bist du im Land der „Computer Vision“.

2) Wörter sind eine Illusion

Es gibt keine Leerzeichen, es sei denn, eine Glyphe ist buchstäblich ein Leerzeichen. Viele PDFs zeichnen „CHAMPAGNE“ als neun unabhängige Glyphen mit willkürlichen Lücken. Ob etwas ein Wort oder zwei zufällig nahe beieinander liegende Spalten sind, ist deine Aufgabe, durch Clustern von Koordinaten zu erschließen.

Symptom: Dein „Split on Spaces“ gibt ['CH', 'AMP', 'AGNE'] zurück oder, schlimmer noch, verschmilzt zwei Spalten zu einem Monsterwort.

Lösungsansatz: Rekonstruiere die Lesereihenfolge unter Verwendung der Textmatrix und Toleranzschwellen für X/Y-Lücken. Erwarte, dass du pro Dokument feinjustieren musst.

3) Layouts sind maßgeschneiderte Schneeflocken

Bei Bank A ist der „Betrag“ vielleicht rechtsbündig bei x=480; Bank B verwendet eine Tabelle; Bank C rendert jede Ziffer einzeln mit TJ, um Dezimalstellen auszurichten. Das einzig Konsistente an „Beschreibung / Datum / Betrag“ ist, dass es inkonsistent ist.

Symptom: Dein regelbasierter Parser funktioniert bei 9 PDFs und verschluckt beim 10. stillschweigend Nachkommastellen.

Lösungsansatz: Trenne Rendering von Semantik. Baue ein visuelles Modell (Boxen, Linien, Textläufe) und dann eine gelernte Zuordnung von Visuals → Feldern. Oder pflege Templates pro Aussteller, wenn dein langfristiger Plan Frust beinhaltet.


Ein kleines Experiment: Erstellen, dann extrahieren

Zuerst generieren wir einen Spielzeug-Kontoauszug. Jede PDF-Bibliothek funktioniert; der Schlüssel ist: wir rendern, was Menschen sehen, nicht „strukturierte Daten“.

# Erzeuge einen einseitigen Auszug, wie Menschen ihn sehen
open_page(width=letter, height=letter)
draw_text("Sample Bank Statement", at=(x_left, y_top))
draw_text("Date", at=(x_date, y_head))
draw_text("Description", at=(x_desc, y_head))
draw_text_right("Amount", right_edge=x_amount_right, y=y_head)

for (date, desc, amount) in transactions:
  draw_text(date, at=(x_date, y))
  draw_text(desc, at=(x_desc, y))
  # kritisch: Betrag Ziffer für Ziffer mit Kerning rendern (TJ)
  draw_digits_with_spacing(amount, right_edge=x_amount_right, y=y)
  y = y - line_height

save_pdf("sample.pdf")

Jetzt versuchen wir zu extrahieren. Wir demonstrieren drei Strategien zuerst in Pseudocode (siehe Anhang für ausführbare Skripte): „String-Hammer“, „naiver Text-Walk“ und „Layout-bewusst“.

# Methode 1: YOLO (String-Jagd)
bytes = read_file("sample.pdf")
visible = grep_text(bytes, patterns=["%PDF", "xref", "/Type", "/Page"])
print(visible)  # findet evtl. Container/Meta-Token; Page-Streams sind komprimiert
# Methode 2: Naiver Text-Walk
lines = []
for page in pdf_pages("sample.pdf"):
  buf = ""
  prev_y = None
  for op in page.text_ops():  # liefert Zeichen in Operator-Reihenfolge
    if op.type == "char":
      if prev_y is not None and (prev_y - op.y) > y_tol:
        lines.append(buf.strip()); buf = ""
      buf += op.text
      prev_y = op.y
    elif op.type in {"space", "newline"}:
      buf += " "
  if buf: lines.append(buf.strip())
print(lines)  # Wörter vermatscht, Spalten verschmolzen, Textsalat wenn ToUnicode fehlt
# Methode 3: Layout-bewusster Graph
runs = []
for page in pdf_pages("sample.pdf"):
  glyphs = [ (g.x, g.y, g.w, g.text) for g in page.glyphs() ]
  lines = cluster_by_y(glyphs, tol=y_tol)
  words = [ cluster_by_x(line, gap=x_tol) for line in lines ]
  right_guide = infer_right_edge([w for line in words for w in line if looks_numeric(w)])
  for line in words:
    amount = snap_right_aligned(line, right_guide, tol=snap_tol)
    date = first_token_like("YYYY-MM-DD", line)
    desc = tokens_between(date, amount, line)
    if date and amount:
      runs.append({
        "date": date.text,
        "description": join(desc),
        "amount": parse_locale_number(amount.text)
      })
print({ "transactions": runs })
{"transactions": [
  {"date": "2024-01-03", "description": "VEGA PARKING - REF: 827492", "amount": -3.45},
  {"date": "2024-01-05", "description": "LUMINA PAYMENTS - Transfer ID: 14x8Nqm7", "amount": 796.60},
  {"date": "2024-01-08", "description": "STELLAR TRANSPORT - Invoice F887", "amount": -63.36}
]}

Spoiler: Methode 1 extrahiert fast nichts. Methode 2 liefert Textsalat für die fehlende /ToUnicode-Zeile und verschmilzt Spalten. Methode 3 ist... endlich brauchbar, bis du das Template einer zweiten Bank ausprobierst.


Der Kontoauszugs-Endgegner

Stellen wir uns vor, ein Kunde lädt drei verschiedene Kontoauszüge für KYC hoch. Für einen Menschen sehen sie gleich aus:

  • Datum, Beschreibung, Betrag.
  • Negative Beträge haben ein Minuszeichen; Dezimalstellen nutzen ein Komma, weil... Europa.

Unter der Haube sind es völlig unterschiedliche Monster:

  1. Aussteller A verwendet ToUnicode und einfaches Tj. Easy Mode.
  2. Aussteller B zeichnet Ziffern mit TJ wie [ (1) -30 (2) -30 (3) -30 (,) -20 (4) ], damit Dezimalstellen ausgerichtet sind. Rechtsbündig bei x≈480.
  3. Aussteller C hat eine Type-3-Schriftart mit benutzerdefinierten Glyphen eingebettet. Kein ToUnicode. Das Komma ist eigentlich Glyphen-ID 17, was deine Bibliothek als \x11 dekodiert.

Eine Pipeline muss:

  • Schriftarten dekodieren, wenn möglich; auf OCR nur dort zurückgreifen, wo nötig.
  • Die Lesereihenfolge durch Geometrie rekonstruieren (nicht Textreihenfolge).
  • Ländereinstellungen normalisieren (, vs. .; geschützte Leerzeichen; Klammern für Negative).
  • „Ziffernsalat“ aus TJ erkennen und beheben, indem tatsächliche Glyphenpositionen berechnet werden.

Hier ist die Art von Nachbearbeitung, die du am Ende schreibst (Pseudocode):

# (Platzhalter) postprocess.py
for run in text_runs:
    if looks_like_right_aligned_amount(run):
        amount = stitch_digits_by_x(run.glyphs, tolerance=1.5)
        amount = normalize_decimal_separators(amount)
        yield {"amount": parse_money(amount), "x": run.right_x}

Und nein, es ist nicht eine Funktion. Es ist ein Garten voller Heuristiken, jede einzelne nach einem Bug-Report adoptiert.


„Aber meins funktioniert bei 95 % der Dateien!“

Meins auch. Das Median-PDF ist in Ordnung. Der „Tail“ ist dort, wo Compliance-Teams leben. Der Long Tail beinhaltet:

  • Scans (Bitmaps), die mit Vektortext verschachtelt sind.
  • Linearisierte PDFs, bei denen Objekte für das Streaming durcheinandergewürfelt werden.
  • Seltsame xrefs und komprimierte Objekt-Streams.
  • Getaggte PDFs, die vorgeben, Struktur zu haben (manchmal hilfreich! manchmal Lügen!).

Du bemerkst diese nicht, bis ein kritischer Kunde genau diesen Export aus genau diesem Kernbankensystem verwendet.


Schlussgedanke → und wie wir das bei Holofin machen

PDFs sind wunderbar darin, wofür sie entworfen wurden: Menschen zuverlässig die gleiche Seite zu zeigen. Wenn du Daten brauchst, frage nach Daten, CSV, JSON, XLSX. Und wenn die Realität sagt „der Regulator will PDFs“, brauchst du eine Pipeline, die PDFs wie die winzigen Grafikprogramme behandelt, die sie sind.

Bei Holofin ist das buchstäblich unsere Stellenbeschreibung: chaotische PDFs aus der realen Welt in strukturierte, validierte, produktionsreife Daten zu verwandeln, von sauberen Exporten bis hin zu kaffeefleckigen Scans aus den 1990ern.

Unsere Prinzipien

  • Struktur vor Semantik. Wir bauen zuerst die Geometrie neu auf (Glyphen → Wörter → Zeilen → Blöcke → Tabellen) und weisen erst dann Bedeutung zu. Das vermeidet „richtige Zahlen, falsche Kopfzeile“-Bugs.
  • Anker überall. Jeder Wert trägt Seitennummer, Bounding Box und Header-Stack-Abstammung, damit du beim Review/Debug zur Quelle zurückklicken kannst.
  • Deterministische Ausgaben. Wir liefern konsistente, prüfbare Werte, die aus dem Dokumenteninhalt abgeleitet sind; Einheiten und Währungen werden so beibehalten, wie sie von der Quelle bereitgestellt wurden.
  • Constraints > Vibes. Summen müssen sich zu Zwischensummen addieren; Bilanzen müssen aufgehen; Daten müssen plausibel sein. Wenn Regeln scheitern, versuchen wir alternative Strategien und Heuristiken.

Wie das auf einem Kontoauszug aussieht

Erinnerst du dich an unsere drei Aussteller (einfaches Tj, Kerning mit TJ, Subset-Fonts ohne ToUnicode)? Holofin handhabt sie durch:

  • Widerstandsfähige Textextraktion: Wir kombinieren native Textdekodierung mit OCR-Fallback, wo angebracht, um konsistente Ergebnisse über Schriftarten, Kodierungen und Scans hinweg sicherzustellen.
  • Geometrie-first Layout-Rekonstruktion: Wir bauen Lesereihenfolge, Zeilen und Spalten anhand der On-Page-Geometrie neu auf, damit Formatierungsunterschiede das Parsing nicht brechen.
  • Domänenbewusste Interpretation: Wir weisen Semantik mit finanziellen Validierungen zu (z. B. müssen Salden und Zwischensummen übereinstimmen), um plausible, aber falsche Werte zu verhindern.
  • Prüfbarer, überprüfbarer Output: Wir geben strukturierte Daten mit Herkunftsnachweis zurück, um menschliche Überprüfung und Rückverfolgbarkeit zu unterstützen.

Warum das wichtig ist

Im Finanzwesen fühlt sich ein Extraktionsfehler von einem Prozent nicht wie ein Tippfehler an, sondern wie eine Bewertungsänderung. Wir entwickeln für Genauigkeit und Konsistenz durch geschichtete Kontrollen, Abgleiche und umfassende Audit-Trails und liefern Zuverlässigkeit, der nachgelagerte Systeme und Prüfer vertrauen können.

Was du out-of-the-box bekommst

  • 97 %+ Zero-Shot-Präzision bei gängigen Finanzdokumenten (und Tools, um die letzten paar Prozent zu überprüfen).
  • Multi-Dokumenten-Verarbeitung (z. B. ein Jahr Kontoauszüge) in einem einzigen API-Aufruf mit konsolidiertem, normalisiertem Output.
  • Debug-Modus mit Quell-Wahrheit & Audit-Trails für jeden Wert.
  • Enterprise-ready REST API und Web UI, gebaut für Skalierung und Sicherheit, mit konfigurierbarer Datenaufbewahrung (standardmäßig DSGVO-freundlich).

Wenn das bei den Narben deines eigenen PDF-Parsers Resonanz erzeugt, lass uns reden.

Verwandte Artikel

Holofin