PDF parsen

Artikel Bild
Manchmal muss man PDF-Dateien auslesen. Dieser Artikel zeigt, wie man das mit einem Python-Skript macht.

Am liebsten schreibe ich Artikel aus dem prallen Leben, also über persönliche oder berufliche Erfahrungen. So auch bei diesem Beitrag. Wir erhalten Pfarraufträge vom Bestattungsamt der Stadt Zürich im PDF-Format. Obwohl wir eine digitale Schnittstelle zum Bestattungsamt haben, werden darüber nicht alle Aufträge übertragen. Ich erspare euch die Details. Für unsere neue Kasualienverwaltung (Taufe, Segnung, Konfirmation, Trauung, Abdankung) müssen die Daten vollständig und detailliert übertragen werden, damit die Register (Kirchenbücher) korrekt generiert werden können. Diese Register sind wichtig, weil sie über Jahrhunderte hinweg Auskunft über familiäre Verläufe belegen. Die Einsicht in die Kirchenbücher wird von der Bevölkerung oft angefragt.

Da eine verbesserte API zwischen der Stadt und der Reformierten Kirchgemeinde Zürich in Arbeit ist (die Stadt braucht jahrelang dafür), brauche ich eine Zwischenlösung. Niemand möchte Daten manuell von einer PDF-Datei in eine Anwendungsmaske übertragen. Deshalb habe ich einen Parser geschrieben, der die PDF-Dateien ausliest und in die Anwendung einliest.

Wer meine Artikel liest, weiss, dass das PDF-Format zu meinen "Lieblingen" gehört: PDF - das Format aus der Hölle, was sich beim Schreiben des Parsers bestätigt hat. Zur Verdeutlichung sei gesagt, dass es PDF-Formulare gibt, aus denen man einfach strukturierte Daten auslesen kann. Doch die normalen PDF-Dateien bestehen aus einer unstrukturierten Bleiwüste.

Aus Datenschutzgründen kann ich nur dieses Dokument zeigen, in dem alle kritischen Daten gelöscht sind. Ein Pfarrauftrag (Bestattungsauftrag) sieht so aus:

Den Parser habe ich in Python geschrieben. Das Skript verwendet die Library pypdf, mit der eine PDF-Datei in Text umgewandelt wird. Das Grundgerüst des Python-Skripts sieht so aus:

#!/usr/bin/env python # -*- coding: utf-8 -*- """ Name: pfarrauftrag.py Description: Extract text from Pfarrauftrag PDF Author: Ralf Hersel License: GPL3 Date: 23.04.2025 Version: 0.02 """ # === Import =================================================================== from pypdf import PdfReader # pip install pypdf # === Constants ================================================================ TAB = 30

Neben dem Shebang und der Coding-Angabe, gibt es ein paar Meta-Infos zum Skript. Danach importiere ich die Python-Bibliothek pypdf und setze die Konstante TAB, mit der die Distanz für die Textausgabe bestimmt wird. Der Main-Teil sieht so aus:

# === Main ===================================================================== def main(args): try: pdf_file = args[1] except IndexError: print("Missing PDF filename") exit(1) pdf = read_pdf(pdf_file) txt = get_text(pdf) print(txt) print('-------------------------------------------------------------------') parse_text(txt) return 0 if __name__ == '__main__': import sys sys.exit(main(sys.argv))

Das ist überwiegend Boilerplate-Code. Am Anfang prüfe ich, ob eine PDF-Datei als Parameter beim Aufruf des Skripts mitgegeben wurde; falls nicht, bricht das Skript mit einer Fehlermeldung ab. Dann importiere ich die PDF-Datei. Dieser Code lautet:

def read_pdf(pdf_file): pdf = PdfReader(pdf_file) return pdf

Ja, es ist nur eine Zeile Code, weshalb ich mir die Funktion hätte sparen können. Dann extrahiere ich den Text aus der PDF-Datei:

def get_text(pdf): txt = '' for page in pdf.pages: txt += page.extract_text() return txt

Hier muss man über die Seiten des PDF-Dokuments iterieren. Die Inhalte werden in der Variablen txt zusammengefasst. Nachdem der gesamte Text der PDF-Datei jetzt in der Variable txt vorliegt, kann der Parser loslegen. Es gibt verschiedene Verfahren dafür; ich habe mich für eine Schlüsselwortsuche entschieden. Dabei wird der Text nach Start- bzw. Stopp-Wörtern durchsucht. Ein zeilenbasierter Ansatz funktioniert hier nicht, weil Anzahl und Position der Zeilen im PDF nicht eindeutig sind. Ich beginne mit einem einfachen Beispiel:

field = "Bestattungstermin" search = "Termin der Bestattung" start = txt.find(search) + len(search) + 1 stop = txt.find("\n", start) value = txt[start:stop]

Hierbei wird das Datum der Bestattung gesucht. Der Suchbegriff lautet "Termin der Bestattung". Zu der gefundenen Startposition wird die Länge des Suchbegriffs addiert, weil dieser nicht zum Inhalt gehört. Als Stopp-Wort dient das Zeilenende. Den Wert ermittle ich durch Slicing: txt[start:stop]. Hier ist ein Textausschnitt, damit ihr es euch besser vorstellen könnt:

Friedhof, Adresse Friedhof Rehalp, Forchstrasse 384, 8008 Zürich Termin der Bestattung Freitag, 12.07.2024, 13.30 Abdankungsort Friedhofkapelle Enzenbühl, Forchstrasse 384, 8008 Zürich

Der gefundene Wert lautet "Freitag, 12.07.2024, 13.30". Manchmal verteilt sich der Wert über mehrere Zeilen, wie man am Abdankungsort sehen kann:

Termin der Bestattung Freitag, 15.01.2021, 14.00 Abdankungsort Friedhofkapelle Hönggerberg, Notzenschürlistr. 30, 8049 Zü- rich Datum/Zeit Freitag, 15.01.2021, 14.00

Hier ist der Code für diesen Fall:

field = "Abdankungsort" search = "Abdankungsort" start = txt.find(search) + len(search) + 1 stop = txt.find("Datum/Zeit", start) value = txt[start:stop] value = value.replace('\n', '')[:-2]

Dabei wird "Datum/Zeit" als Stopp-Wort verwendet, also der Feldname des darauffolgenden Feldes. Ausserdem werden die Zeilenumbrüche im gefundenen Wert entfernt. Schwieriger wird die Sache, wenn der Suchbegriff mehrmals im Text vorkommt, aber nur einer davon der richtige ist. Das ist beim Geburtsdatum der Fall. Der Feldname lautet "geb.". Leider kann dieser Text auch an anderen Stellen vorkommen, z. B. bei "Meier, geb. Müller". Deshalb ist eine iterierende Suche mit Überprüfung des gefundenen Wertes erforderlich:

field = "Geboren" search = "geb." found = False last_start = 0 while not found and last_start >= 0: start = txt.find(search, last_start) + len(search) + 1 stop = txt.find("gest.", start) value = txt[start:stop] if value[0].isnumeric(): found = True else: last_start = start

Mittels einer while-Schleife wird die Suche so lange wiederholt, bis der richtige Wert gefunden wurde. Die Variable found dient als Abbruchkriterium. Ausserdem wird die letzte Startposition behalten, damit die Suche nicht jedes Mal am Anfang des Textes beginnt. Ob der richtige Wert gefunden wurde, überprüfe ich mit dem Test, ob das erste Zeichen des Wertes numerisch ist. Um eine Endlosschleife zu vermeiden, wird in der Bedingung der while-Schleife auch geprüft (last_start >= 0), ob die Suche überhaupt etwas gefunden hat (find liefert -1, falls nichts gefunden wird).

Mit diesen Funktionen und leichten Abwandlungen konnte ich alle 23 Felder der PDF-Datei auslesen. Vielleicht hilft euch dieser Artikel, falls ihr selbst einmal eine unstrukturierte PDF-Datei auslesen möchtet.

Titelbild (verändert): https://pixabay.com/photos/sorrow-grief-mourn-sad-funeral-4900424/

Quelle: keine


GNU/Linux.ch ist ein Community-Projekt. Bei uns kannst du nicht nur mitlesen, sondern auch selbst aktiv werden. Wir freuen uns, wenn du mit uns über die Artikel in unseren Chat-Gruppen oder im Fediverse diskutierst. Auch du selbst kannst Autor werden. Reiche uns deinen Artikelvorschlag über das Formular auf unserer Webseite ein.