Text aus .*pdf lesen und Schlüsselwörter finden

Wenn du dir nicht sicher bist, in welchem der anderen Foren du die Frage stellen sollst, dann bist du hier im Forum für allgemeine Fragen sicher richtig.
Antworten
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Guten Morgen zusammen,

da hier ja zur Zeit nicht so viel los ist, passt es ganz gut, das ich mal wieder vor einem Problem stehe.
Mein Ziel ist es aus einer *.pdf - Datei gezielt einige Informationen zu lesen. Dass die Dateien mehr oder weniger willkürlich aufgebaut sein können habe ich von euch schon gelernt. Ich habe das Glück, dass ich problemlos mit `PyPDF2` den gesamten Text auslesen kann. Der Aufruf sieht so aus:

Code: Alles auswählen

pdf_pages = PdfReader(pdf, strict=True).pages
Wenn ich dann über die Seiten iteriere, sieht eine Seite so aus (gekürzt und Inhalt anonymisiert):

Code: Alles auswählen

'XX/XX/2019of 123456 Customer Order No. 987654 10 Page 5 From
                '
                 ' 
                '
                 'data
                '
                 'Diesel Fuel type/Fuel mixture
                '
                 'Machine ABCDE 12345
                '
                 '- Tire, Diameter 250/18xy, steel
                '
                
Die *.pdf Datei sieht aber eigentlich so aus:

Code: Alles auswählen

Order No. 987654 of XX/XX/2019 Customer 123456 Page 5 From 10
data
Fuel type/Fuel mixture Diesel
Machine ABCDE 12345
-Tire, Diameter 250/18xy, steel
Also manche Sätze werden verdreht.

Mein Ziel ist ein Wörterbuch das so aussieht:

Code: Alles auswählen

{
    'order_number': '123456',
    'fuel': 'Diesel', 
    'machine_type': 'ABCDE 12345',
    'tire_diameter': '250/18'
Der Plan ist, das ich mit regulären Ausdrücken die Seiten durch gehe und mir erst mal den Teil aus den Sätzen hole, der mich interessiert. Im weiteren Schritt würde ich die Teile dann formatieren und dann das Wörterbuch erstellen. Allerdings glaube ich, dass ich den ersten Teil schon falsch angehe.

Ich habe mal alles reduziert um den ersten Schritt zu zeigen, das sieht so aus:

Code: Alles auswählen

from icecream import ic
import re

TEST_STRING = """XX/XX/2019of 123456 Customer Order No. 987654 10 Page 5 From
                '
                 ' 
                '
                 'data
                '
                 'Diesel Fuel type/Fuel mixture
                '
                 'Machine ABCDE 12345
                '
                 '- Tire, Diameter 250/18xy, steel
                '
                """

DETAILS_TO_BE_SEARCHED_EN = [
    "Order No.",
    "Machine",
    "Fuel type/Fuel mixture",
    "Tire, Diameter",
]


def order_details(text, pattern):
    for pattern in pattern:
        try:
            search_result = re.search(pattern, text).group().strip()
        except AttributeError:
            search_result = ""
        ic(search_result)


def main():
    pattern = [
        f"({key_word}(.*))|((.*){key_word})" for key_word in DETAILS_TO_BE_SEARCHED_EN
    ]
    order_details(TEST_STRING, pattern)


if __name__ == "__main__":
    main()
Und ergibt das:

Code: Alles auswählen

ic| search_result: 'XX/XX/2019of 123456 Customer Order No.'
ic| search_result: "'Machine"
ic| search_result: "'Diesel Fuel type/Fuel mixture"
ic| search_result: "'- Tire, Diameter"
Ist mit dem `|` im regularen Ausdruck vermutlich auch erwartbar. Nur leider finde ich keine weitere Lösung. Ich würde gerne durch die Zeilen iterieren und mit `in` nach den Schlüsselwörter suchen, aber ich habe keine Zeilen im eigentlichen Sinne, sondern nur einzelne Strings/Zeichen.

Könnt ihr mir bitte helfen, wie ich das am besten von Anfang an aufbauen könnte?

Vielen Dank und Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
__blackjack__
User
Beiträge: 13123
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Dennis89: Wie sieht das denn im PDF aus? Ist das eine Tabelle? Werden auch die Koordinaten bei der Textextraktion berücksichtigt?
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Danke für die schnelle Antwort.

Das sieht (gekürzt) so aus:
https://imgur.com/OKsQpUt

Unter `data`und `Machine` stehen in echt mehrere Einträge, aber alle in dem Format wie das Beispiel.

Koordinaten wurde nicht berücksichtigt. Hier wird was mit Koordinaten gemacht. Meinst du sowas? Bzw. das ich mir mal die Koordinaten mit ausgeben lassen soll?

Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Sirius3
User
Beiträge: 17761
Registriert: Sonntag 21. Oktober 2012, 17:20

@Dennis89: ja, am besten versuchst Du auch die Position auf der Seite mit zu berücksichtigen.
Deine regulären Ausdrücke sind falsch. Zum einen ist immer irgendwas vor oder hinter Deinem Keyword. Zum anderen enthält key_word auch Zeichen, die eine besondere Bedeutung haben und deshalb mit re.escape geschütz werden müssen.
AttributeError ist immer ein Programmierfehler, und sollte deshalb nicht auftreten. Besser ist es, das Ergebnis von re.search auf None zu prüfen.
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Danke für die Antwort.
Wenn ich folgendes mache:

Code: Alles auswählen

from icecream import ic
from pathlib import Path
from PyPDF2 import PdfReader


PDF_TEST_PATH = Path(r"C:\Users\xxx\xxx\xx\pdf_test\AB.pdf")


def visit_text(text, cm, tm, font, fonz_size):
    ic(f'{text} x:{tm[4]} und y:{tm[5]}')


def main():
    reader = PdfReader(PDF_TEST_PATH)
    page = reader.pages[6]
    page.extract_text(visitor_text=visit_text)


if __name__ == "__main__":
    main()
Und entsprechende auch für die andere Seite, auf der die anderen Informationen stehen, dann bekomme ich folgende Infos.
Diese Zeile

Code: Alles auswählen

Order No. 987654 of XX/XX/2019 Customer 123456 Page 5 From 10
teilt sich so auf bzw `ic` gibt mir das einzeln so aus:

Code: Alles auswählen

'XX/XX/2019 x:288.0 und y:705.86'
'of x:273.92 und y:705.86'
'123456 x:416.0 und y:705.86'
'Customer x:365.81 und y:705.86'
'Order No. 987654 x:23.0 und y:705.86'
'10 x:551.0 und y:705.86
'Page 5 From x:490.08 und y:705.86'
Zeile Finde ich so vor:

Code: Alles auswählen

'data x:55.0 und y:682.17'
Zeile

Code: Alles auswählen

Fuel type/Fuel mixture Diesel
so:

Code: Alles auswählen

'Diesel x:293.0 und y:668.17'
'Fuel type/Fuel mixture x:55.0 und y:668.17'
Zeile

Code: Alles auswählen

Machine ABCDE 12345
so:

Code: Alles auswählen

'Machine ABCDE 12345 x:55.0 und y:201.97'
Zeile

Code: Alles auswählen

-Tire, Diameter 250/18xy, steel
so:

Code: Alles auswählen

'- Piston, Diameter 170/122mm x:55.0 und y:639.57'
In der *.pdf-Datei steht meine gesuchte Information immer hinter dem Schlüssel Wort. Sollte ich jetzt das Wort suchen, mir die Position merken, auf den x-Wert die Länge des Wortes addieren und ab diesem Bereich dann meine Information suchen? Hört sich zumindest mal logisch an oder was meint ihr?

Noch kurz meine Gedanken, wieso ich `AttributeError` verwendet habe. Damit prüfe ich indirekt auch auf `None`. Wenn `re.search` nichts findet ist es `None` und es hat kein `group`. Wollte damit eigentlich eine kompakte Funktion schreiben. Aber wenn man das in der Praxis nicht anwendet, dann schreibe ich das um

Code: Alles auswählen

def order_details(text, pattern):
    for pattern in pattern:
        search_result = re.search(pattern, text)
        if search_result is None:
            search_result = ""
        else:
            search_result.group().strip()
        ic(search_result)
Bzw. (ich mag kompakt):

Code: Alles auswählen

def order_details(text, pattern):
    for pattern in pattern:
        search_result = re.search(pattern, text)
        search_result = "" if search_result is None else search_result.group().strip()
        ic(search_result)
Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Sirius3
User
Beiträge: 17761
Registriert: Sonntag 21. Oktober 2012, 17:20

Je nach Information musst du halt anders vorgehen. Du hast die header Zeile, eine tabellenartige Struktur und eine Art Aufzählung. Manchmal bekommst du die Informationen als einen String, den kannst dann mit regulären Ausdrücken verarbeiten. Bei der Tabelle musst du eben die verschiedenen Spalten anhand ihrer y-Wertes zuordnen.
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Okay, vielen Dank.

Ich schaue mal wie ich das umsetze.


Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
noisefloor
User
Beiträge: 3858
Registriert: Mittwoch 17. Oktober 2007, 21:40
Wohnort: WW
Kontaktdaten:

Hallo,

bzgl. der Koordinaten noch eine Ergänzung: was bei einem PDF auf dem Bildschirm oder gedruckt hintereinander steht muss um PDF nicht hintereinander stehen. Kann, muss aber nicht. Mann könnte grundsätzlich bei auch X Textfelder mit einzelnen Worten oder Phrasen haben, die alle Koordinaten haben und so das PDF gebaut wird. Da die Wortposition durch die Koordinaten festgelegt sind kann die Reihenfolge, in der die Worte oder Phrasen im PDF vorkommen, beliebig sein. Bei Fließtext ist das vielleicht nicht so wahrscheinlich, dass das so ist, aber bei Tabellen und tabellen-ähnlichen Strukturen kann das schon sein.

Ich hatte mal ein Teil eines PDFs mit nltk (natural language toolkit) zerlegt und nach einzelnen Worten gesucht - hat einwandfrei funktioniert. Ich habe aber nicht die Fall gehabt, wie du, also "Suche Wort $FOO und gebe das folgenden Wort aus". Von daher war mir die Reihenfolge egal.

Hier mal der Code, vielleicht nützt es ja was:

Code: Alles auswählen

import os
import PyPDF2
from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords
...
for file in os.listdir('.'):
    if file.startswith('DR0') and file.endswith('.pdf'):
        all_keywords = []
        try:
            with open(file, 'rb') as pdf_file:
                pdf_file = PyPDF2.PdfFileReader(pdf_file)
                for page_number in range(3, 7):
                    page = pdf_file.getPage(page_number)
                    text = page.extractText()
                    tokens = word_tokenize(text)
                    stop_words = stopwords.words('english')
                    keywords = [word for word in tokens if not word in stop_words and
                        not word in string.punctuation]
                    all_keywords.extend(keywords)
        except PyPDF2.utils.PdfReadError:
            print(f'{file} is encrypted')
...
Der Code ist etwas älter, heute würde ich pathlib statt os benutzen.

Gruß, noisefloor
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Hallo,

danke für dein Beispiel. Gerade weil das jetzt nicht so schön hintereinander auftaucht, wie ich es in der *.pdf-Datei lese ist mir jetzt nur ein etwas umständlicher weg eingefallen. Da die Info die ich aus dem Header brauche in einem String steht, habe ich das mal noch nicht betrachtet, weil ich eher Probleme mit der tabellarischen Darstellung habe. Dafür habe ich jetzt nachfolgenden Code. Meide Gedanken dazu: Ich habe Wörter die das Detail, das ich suche, beschreiben. Diese Wörter suche ich und merke mir die Koordinaten dazu. Dann muss mein Detail die gleiche y-Koordinate haben und da das Detail weiter rechts steht, muss die x-Koordinate größer sein. Nur sind für meinen Ansatz einige Wörterbücher und Schleifen notwendig.

Code: Alles auswählen

from icecream import ic
from pathlib import Path
from PyPDF2 import PdfReader
from attr import attrs, field


PDF_TEST_PATH = Path(r"C:\Users\xxx\pdf_test\AB.pdf")
NO_VALID_TEXT = ["'", " ", ""]

DETAIL_DESCRIPTION_TO_BE_SEARCHED_EN = [
    "Fuel type/Fuel mixture",
    "speed min/max"
]


@attrs(frozen=False)
class PdfText:
    text_to_coordinates = field()

    def assign_text_to_coordinates(self, text, cm, tm, font, fonz_size):
        for no_valid in NO_VALID_TEXT:
            if text == no_valid:
                return
        self.text_to_coordinates[text] = (tm[4], tm[5])


def get_order_details(pdf_text):
    detail_description_to_coordinates = {}
    for text, coordinates in pdf_text.text_to_coordinates.items():
        for description in DETAIL_DESCRIPTION_TO_BE_SEARCHED_EN:
            if description in text:
                detail_description_to_coordinates[description] = coordinates
    description_to_details = {}
    for description, (detail_x, detail_y) in detail_description_to_coordinates.items():
        for text, (text_x, text_y) in pdf_text.text_to_coordinates.items():
            if detail_y == text_y and detail_x < text_x:
                description_to_details[description] = text
    return description_to_details


def main():
    pdf_text = PdfText(dict())
    reader = PdfReader(PDF_TEST_PATH)
    page = reader.pages[5]
    page.extract_text(visitor_text=pdf_text.assign_text_to_coordinates)
    ic(get_order_details(pdf_text))


if __name__ == "__main__":
    main()
Ergibt:

Code: Alles auswählen

ic| description_to_details: {'speed min/max': '880 rpm',
                             'Fuel type/Fuel mixture': 'Diesel'}
Okay funktioniert für eine Seite, aber ich habe mich sicherlich etwas verzettelt mit den Schleifen?

Danke und Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
__blackjack__
User
Beiträge: 13123
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

Das erstellen des leeren Wörterbuchs würde ich in die `field()`-”Deklaration” verschieben. Und entweder statt `field()` `attrib()` verwenden oder statt `attrs()` `define()`, aber nicht die beiden APIs mischen.

`assign_text_to_coordinates()` lässt sich kompakter formulieren.

In `get_order_details()` kann man die Schleifen nach einem Suchtreffer abbrechen.

Code: Alles auswählen

from pathlib import Path

from attrs import define, field
from icecream import ic
from PyPDF2 import PdfReader

PDF_TEST_PATH = Path(R"C:\Users\xxx\pdf_test\AB.pdf")
NO_VALID_TEXT = {"'", " ", ""}

DETAIL_DESCRIPTION_TO_BE_SEARCHED_EN = [
    "Fuel type/Fuel mixture",
    "speed min/max",
]


@define
class PdfText:
    text_to_coordinates = field(factory=dict)

    def assign_text_to_coordinates(self, text, _cm, tm, _font, _font_size):
        if text not in NO_VALID_TEXT:
            self.text_to_coordinates[text] = (tm[4], tm[5])


def get_order_details(pdf_text):
    description_to_coordinates = {}
    for text, coordinates in pdf_text.text_to_coordinates.items():
        for description in DETAIL_DESCRIPTION_TO_BE_SEARCHED_EN:
            if description in text:
                description_to_coordinates[description] = coordinates
                break

    description_to_details = {}
    for description, (
        detail_x,
        detail_y,
    ) in description_to_coordinates.items():
        for text, (text_x, text_y) in pdf_text.text_to_coordinates.items():
            if detail_y == text_y and detail_x < text_x:
                description_to_details[description] = text
                break

    return description_to_details


def main():
    pdf_text = PdfText()
    PdfReader(PDF_TEST_PATH).pages[5].extract_text(
        visitor_text=pdf_text.assign_text_to_coordinates
    )
    ic(get_order_details(pdf_text))


if __name__ == "__main__":
    main()
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Super, vielen Dank.

Dann lag ich mit meinen Wörterbücher ja ganz gut. Die restlichen Anmerkungen habe ich auch eingebaut.
Wieso hast du aus meiner Liste `NO_VALID_TEXT` ein `set` gemacht?

Eigentlich nicht wirklich wichtig, aber nutzt du noch `black` oder hast du den Code von Hand so formatiert? Mir fiel nur auf, dass die Schleife in Zeile 34 `black` bei mir in einer Zeile gelassen hat und bei dir wurde die, von wem auch immer, auf mehrere Zeilen aufgeteilt.


Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
__blackjack__
User
Beiträge: 13123
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

`set` ist bei ``in`` effizienter, wobei das bei drei Elementen wahrscheinlich nicht wirklich einen merkbaren Unterschied macht.

Hast Du die maximale Zeilenlänge bei Black auch auf 79 Zeichen eingestellt? Auch Diffs mag ich gerne so das sie noch in 80 Zeichen-Terminals passen. 😎
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

`set` ist bei ``in`` effizienter
Hättest du geschrieben, dass es dir besser gefällt, hättest du jetzt deine Ruhe. Lässt es sich in ein paar Sätzen erklären, warum das effizienter ist?

Ne, ich habe `Black` in PyCharm eingebunden und bin mit den Standardeinstellungen eigentlich zufrieden. Das passt auch sehr gut für mich, wenn ich den Bildschirm teile um beispielsweise im Browser Code von euch zu klauen oder ähnliches. 😇

Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
noisefloor
User
Beiträge: 3858
Registriert: Mittwoch 17. Oktober 2007, 21:40
Wohnort: WW
Kontaktdaten:

Hallo,

schau mal hier: https://wiki.python.org/moin/TimeComplexity. Ein `in` Operation auf ein set ist normalerweise O(1), d.h. dauert immer gleich lang, egal wie groß das set ist, und schlechtestenfalls O(n). Bei einer Liste ist es immer O(n), d.h. abhängig von der Anzahl der Elemente. Das "warum" liegt dann wohl in der internen Implementierung der Datenstrukturen in CPython.

Wirklich relevant wird das aber wohl erst, wenn du entweder wirklich große Datenmengen hast oder sehr viele Operationen auf Datensätze.

Gruß, noisefloor
Sirius3
User
Beiträge: 17761
Registriert: Sonntag 21. Oktober 2012, 17:20

Wobei diese 1 von O(1) deutlich aufwändiger sein kann, als 1,5 einfache Vergleiche, womit die Liste effektiv eventuell sogar schneller wäre.
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Hallo,

vielen Dank.

Ich weis zwar nicht wieso die 1 aufwändiger sein kann, aber daraus schließe ich, dass eine Liste bis zu einer gewissen Länge doch effektiver als ein `set` ist?

Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Benutzeravatar
__blackjack__
User
Beiträge: 13123
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

Auf dieser Maschine unter meinem Schreibtisch ist das mit `set` cirka doppelt so schnell:

Code: Alles auswählen

$ python3.10 -m timeit -s "NO_VALID_TEXT = ['\"', ' ', '']" '"x" not in NO_VALID_TEXT'
5000000 loops, best of 5: 48.8 nsec per loop
$ python3.10 -m timeit -s "NO_VALID_TEXT = {'\"', ' ', ''}" '"x" not in NO_VALID_TEXT'
10000000 loops, best of 5: 24 nsec per loop
Aber: Microbenchmarks. Im tatsächlichen Programm wird das von der ganzen anderen Arbeit überschattet und wird kaum einen Unterschied machen.

Und bitte den Unterschied zwischen „effizient“ und „effektiv“ nachlesen. 🤓
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
Dennis89
User
Beiträge: 1158
Registriert: Freitag 11. Dezember 2020, 15:13

Oh, da habe ich das "effektiv" glatt als "effizient" gelesen. Hatte Sirius3 Beitrag erst gesehen, als ich vor dem abschicken noch mal aktualisiert habe. Sorry 🫣

Nun sind alle Klarheiten besei... äh Unklarheiten beseitigt, Danke 😊

Grüße
Dennis
"When I got the music, I got a place to go" [Rancid, 1993]
Antworten