Technischer Artikel

Text aus geladenem PDF in Delphi mit HotPDF extrahieren

HotPDF Delphi Component extrahiert Unicode-Text aus jedem PDF, das Sie in Delphi laden, über zwei Aufrufe: ExtractLoadedPageText liefert den Text einer Seite im Lesefluss, und ExtractLoadedPageTextLayout (hinzugefügt in v2.263.0) rekonstruiert die visuelle Anordnung der Seite als reinen Text, sodass Spalten, Einrückungen und Tabellenausrichtung in der Ausgabe erhalten bleiben. Beide arbeiten auf Dokumenten, die HotPDF nicht selbst erzeugt hat, und genau dieser Fall zählt in der Praxis: die Rechnung, die ein Kunde per E-Mail geschickt hat, der Bericht, den ein Scan-Dienstleister geliefert hat, der Vertrag, den eine Software erzeugt hat, deren Namen niemand mehr kennt

Bis dahin war mehr Mechanik nötig, als die beiden Signaturen vermuten lassen, denn ein PDF speichert Text nicht so wie eine Textdatei. Dieser Artikel führt durch beide Extraktionsmodi und öffnet dann die Motorhaube über den drei Bausteinen darunter — dem CMap-Reader, dem Content-Stream-Interpreter und der Font-Decode-Fallback-Kette —, denn zu wissen, wie die Zuordnung funktioniert, macht den Unterschied zwischen Achselzucken über unbrauchbare Ausgabe und ihrer Diagnose

HotPDF: Dieselbe geladene PDF-Seite, per ExtractLoadedPageText als Lesefluss-Text und per ExtractLoadedPageTextLayout als layouterhaltendes Zeichenraster in Festbreitenschrift extrahiert
Die beiden Modi teilen sich jedes Byte der Decode-Mechanik und unterscheiden sich nur darin, wie sie die decodierten Glyphen anordnen

Warum ist Textextraktion schwieriger, als Zeichenketten aus der Datei zu lesen?

Ein PDF-Content-Stream speichert Zeichencodes, keine Zeichen. Die Operatoren Tj und TJ (ISO 32000-1 §9.4.3) tragen Byte-Strings, deren Bedeutung vollständig von der Schrift abhängt, die das vorangehende Tf ausgewählt hat: Byte 0x41 kann unter WinAnsi der Buchstabe A sein, in einer Subset-Schrift eine beliebige Glyphe oder in einer zusammengesetzten CJK-Schrift die Hälfte einer Zwei-Byte-CID. ISO 32000-1 §9.10 definiert Textextraktion als genau dieses Decodierungsproblem — jeden Code mit den Informationen, die das Schrift-Dictionary bereitstellt, auf Unicode zurückzuführen —, und der Standard sagt ausdrücklich, dass eine konforme Datei nicht verpflichtet ist, dafür genügend Informationen mitzuliefern

Dieser letzte Halbsatz erklärt jeden Fehlerbericht der Art „Warum liefert Kopieren und Einfügen aus diesem PDF nur Zeichensalat“, den Sie je gesehen haben. Ein Erzeuger, der eine Subset-Schrift ohne /ToUnicode-Tabelle einbettet, hat eine Datei geschrieben, die perfekt rendert und als Unsinn extrahiert wird, weil die Zuordnung von Code zu Glyphe existiert, die Zuordnung von Code zu Unicode aber nie mitgeliefert wurde. Jede ehrliche Extraktions-API ist daher eine Best-Effort-Kette von Fallbacks, und die nützliche Frage lautet, wie tief diese Kette reicht

Extraktion im Lesefluss mit ExtractLoadedPageText

Für Suchindizierung, Schlüsselwortabgleich oder das Befüllen einer Analyse-Pipeline ist ExtractLoadedPageText der richtige Aufruf. Die Signatur lautet function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — Seitenindizes sind nullbasiert, das Ergebnis kommt als nativer Delphi-UnicodeString, und die Funktion gibt False zurück, wenn die Seite keinen lesbaren Content-Stream hat, statt eine Exception auszulösen

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText enthält jetzt den Lesefluss-Text des Dokuments
  finally
    Pdf.Free;
  end;
end;

Zeilenumbrüche in der Ausgabe stammen aus einer bewusst einfachen geometrischen Regel. Seit HotPDF v2.766.79 wird ein Zeilenumbruch eingefügt, wenn der Glyphen-Ursprung die Schreibrichtung um mehr als die halbe Texthöhe überschreitet, gemessen als das Größere aus der Box-Höhe von Oberlänge bis Unterlänge dieser Glyphe und der vorherigen Glyphe in Seiteneinheiten. Vor diesem Release wurde die vertikale Bewegung mit der halben Schriftgröße verglichen, die an Tf übergeben wird, was in Dokumenten, die Text über die Textmatrix dimensionieren, oft 1 ist; ein hochgestellter Index begann daher eine eigene Zeile, und rotierter Text stellte jedes Zeichen auf eine eigene Zeile. Wortabstände folgen seit v2.766.76 einer passenden Regel: Trennt ein Producer Wörter, indem er die Textposition verschiebt — mit einer TJ-Justierung oder einem Td —, statt ein Leerzeichen anzuzeigen, wird ein Leerzeichen eingefügt, wenn der Abstand entlang der Grundlinie nach der eigenen Breite der vorherigen Glyphe 0,15 der Box-Höhe der Glyphe überschreitet, und nie zwischen zwei chinesischen, japanischen oder koreanischen Zeichen. Zeichen, die der Decoder nicht auflösen kann, werden zu Leerzeichen statt zu verschwinden, sodass Wortgrenzen erhalten bleiben, selbst wenn einzelne Glyphen es nicht tun. Was dieser Modus nicht versucht, ist Lesereihenfolgen-Clustering oder Mehrspaltenerkennung: Eine zweispaltige Seite kommt in Content-Stream-Reihenfolge verschachtelt heraus, was meist, aber nicht immer die visuelle Reihenfolge ist

Wann sollten Sie stattdessen die layouterhaltende Extraktion verwenden?

ExtractLoadedPageTextLayout ist der richtige Aufruf, wann immer die Position Bedeutung trägt: Tabellen, Formulare, Code-Listings, alles, was Sie per Diff vergleichen, mit grep durchsuchen oder spaltenweise parsen möchten. Statt Glyphen zu einem Strom plattzudrücken, gruppiert er sie zu Grundlinien, sortiert jede Grundlinie nach X und bildet horizontalen und vertikalen Weißraum auf einem Zeichenraster in Festbreite nach, das aus dem Median der Glyphenvorschübe und der Schriftgröße dimensioniert wird. Breite Lücken zwischen Textläufen auf derselben Grundlinie werden zu Folgen von Leerzeichen; große Abstände zwischen Grundlinien werden zu Leerzeilen. Das Ergebnis liest sich so, wie die Seite aussieht

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // Spalten, Einrückung und Tabellenausrichtung bleiben als
  // Leerzeichen und Leerzeilen auf einem Zeichenraster erhalten
end;

Die beiden Modi teilen sich jedes Byte der Decodierungsmechanik und unterscheiden sich nur darin, wie sie die decodierten Glyphen anordnen, sodass die Wahl nichts an Genauigkeit kostet. Wählen Sie ExtractLoadedPageText, wenn nur die Wörter zählen, und ExtractLoadedPageTextLayout, wenn die Anordnung zählt. Die Erkennung der Lesereihenfolge bei mehreren Spalten bleibt für beide außerhalb des Umfangs — eine Rasterdarstellung einer zweispaltigen Seite zeigt Ihnen beide Spalten nebeneinander, originalgetreu, was für Diffs genau richtig ist und für den Fließtext-Umbruch nicht

Wie decodiert HotPDF Zeichencodes zu Unicode?

HotPDF Delphi Component löst jeden Zeichencode über eine nach Priorität geordnete Fallback-Kette auf: zuerst die eingebettete /ToUnicode-CMap der Schrift, dann der /Encoding-Eintrag (Stream oder benannte CMap), dann — bei zusammengesetzten Schriften — die Adobe-Standard-CMap-Dateien für Zeichensammlungen wie Adobe-GB1, Adobe-CNS1, Adobe-Japan1 und Adobe-KR, und schließlich die eingebauten WinAnsi- und MacRoman-Tabellen für einfache Schriften. Eine Strategie, die keine Antwort liefern kann, fällt stillschweigend auf die nächste zurück, statt eine Exception auszulösen, und ein Code, der die gesamte Kette erschöpft, wird zu 0 aufgelöst, sodass der Aufrufer Fehlschläge zählen kann, statt zu raten

Die /ToUnicode-CMap (ISO 32000-1 §9.10.3) steht an erster Stelle, weil sie die eine Zuordnung ist, die der Erzeuger speziell für die Extraktion geschrieben hat. Der Pfad über die Adobe-Standard-CMaps ist wichtig für CJK-Dokumente, die vordefinierte CMaps wie UniGB-UTF16-H verwenden, statt irgendetwas einzubetten: HotPDF liefert die Sammlungsdateien im Verzeichnis resources\CMap mit, findet sie zur Laufzeit relativ zur ausführbaren Datei und cacht jede geparste Map pro Prozess — das lohnt sich zu wissen, denn die größte davon, die Adobe-GB1-Map, umfasst rund 2 MB Quelltext, den Sie nicht pro Seite neu parsen möchten. Fehlt das Verzeichnis, überspringt der Decoder die dateibasierten CMaps einfach und arbeitet mit eingebetteten Tabellen plus den eingebauten Encodings. Das ist das leseseitige Spiegelbild des Shaping-Problems, das in Text-Shaping für komplexe Schriftsysteme mit HotPDF behandelt wird, wo dieselbe Unterscheidung zwischen Code und Glyphe beim Schreiben auftritt

HotPDF: Diagramm der Unicode-Fallback-Kette, die PDF-Zeichencodes über die ToUnicode-CMap, das Font-Encoding, Adobe-Standard-CMaps und eingebaute WinAnsi-/MacRoman-Tabellen zu aufgelöstem Unicode-Text oder einer zählbaren Null führt
Jede Strategie fällt stillschweigend auf die nächste zurück, wenn sie einen Code nicht zuordnen kann, und das Erschöpfen der Kette liefert Nullen, die Sie als Qualitätssignal zählen können

Zwei CMap-Syntaxfallen, die man kennen sollte

CMap-Dateien sehen trivial parsebar aus und sind es nicht, und zwei Details erklären die meisten Parserfehler beim ersten Versuch. Das erste: Die Datensatzanzahl steht vor dem Abschnittsschlüsselwort; ein Abschnitt lautet 2 beginbfchar, nicht beginbfchar 2. Ein Parser, der die Anzahl nach dem Schlüsselwort erwartet, verbraucht die Zahl als verirrtes Token und findet dann in jedem Abschnitt null Einträge. Der robuste Ansatz — der, auf den sich der Reader von HotPDF festgelegt hat — besteht darin, die Anzahl komplett zu ignorieren und bis zum passenden Schlüsselwort endbfchar / endbfrange zu iterieren, was als Bonus reale Dateien toleriert, deren Zählwerte schlicht falsch sind

Die zweite Falle: Die Ziele von bfchar und bfrange sind UTF-16BE-Strings, keine Ganzzahlen. Das Ziel <D83DDE00> bedeutet U+1F600 — ein Surrogatpaar, das zu einem Codepunkt wieder zusammengesetzt werden muss —, und das Lesen dieser vier Bytes als Big-Endian-Ganzzahl ergibt bei jedem Codepunkt außerhalb der Basic Multilingual Plane einen bedeutungslosen Wert. Emoji in PDFs sind längst nichts Exotisches mehr, daher scheitert ein Decoder, der die Surrogat-Rekombination auslässt, an Dateien, die Ihre Nutzer tatsächlich haben. HotPDF parst das Hex-Literal zuerst zu rohen Bytes und setzt dann die UTF-16BE-Codeeinheiten wieder zusammen, was auch die mehrstelligen Ziele abdeckt, die Ligaturzuordnungen erzeugen

HotPDF: Diagramme der CMap-Parsing-Fallen, die die vor beginbfchar stehende Datensatzanzahl und das Surrogatpaar D83DDE00 zeigen, das als Hex-Bytes geparst und dann zu U+1F600 zusammengesetzt wird
Robustes CMap-Parsing iteriert über deklarierte Zählwerte hinweg und fügt UTF-16BE-Surrogatpaare zusammen, bevor es auf Unicode abbildet

Hinab auf Glyphenebene mit ExtractLoadedPageGlyphs

Beide Textaufrufe bauen auf ExtractLoadedPageGlyphs auf, und das zugrunde liegende THPDFGlyphArray steht auch Ihrem Code zur Verfügung. Jeder THPDFGlyphRecord trägt neben dem rohen Zeichencode den aufgelösten Unicode-Codepunkt, die Bytebreite des Codes (1, 2 oder 4, festgelegt durch die codespacerange der CMap), den aktiven Schriftressourcenschlüssel und die Schriftgröße, den X- und Y-Ursprung im Benutzerraum sowie den horizontalen Vorschub; seit v2.766.76 zusätzlich GlyphEndX und GlyphEndY, das Ende der eigenen Breite der Glyphe im Seitenraum ohne Zeichen- und Wortabstand, woran die obige Wortabstandsregel misst. Das reicht, um Wortgrenzenerkennung, positionierte Hervorhebung oder einen eigenen Layout-Algorithmus zu bauen, ohne den Content-Stream selbst anzufassen

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

Das Zählen der Datensätze mit Unicode = 0, wie oben, ist der ehrliche Weg, die Extraktionsqualität eines gegebenen Dokuments zu messen, bevor Sie dem Text nachgelagert vertrauen. Die Glyphendatensätze verankern jedes Zeichen außerdem am Quelloperanden im Content-Stream, und genau das macht die Textsuche und -ersetzung von HotPDF in geladenen Dokumenten auf derselben Grundlage möglich

Welche PDFs geben ihren Text nicht her?

Manche Dateien besiegen jeden Extraktor, und es ist besser, sie zu erkennen, als ihre Ausgabe auszuliefern. Gescannte Dokumente sind der offensichtlichste Fall: Eine Seite, die aus einem einzigen großen Bild besteht, enthält überhaupt keine Textoperatoren, daher liefert die Extraktion korrekt eine leere Zeichenkette — die Lösung ist OCR, und das Extrahieren der Seitenbilder aus dem geladenen PDF ist der erste Schritt dieser Pipeline. Subset-Schriften ohne /ToUnicode-Tabelle sind der schwierigere Fall: Wenn auch der /Encoding-Pfad und die Standard-CMaps nichts liefern, werden diese Glyphen zu 0 aufgelöst und erscheinen in den Textaufrufen als Leerzeichen. Verschlüsselte Dokumente werden normal extrahiert, sofern Sie sie über die LoadFromFile-Überladung mit ihrem Passwort laden, sodass die Streams entschlüsselt sind, bevor der Interpreter sie je zu sehen bekommt

Eine engere Grenze verdient es, klar benannt zu werden: Die Decode-Kette liest CMap- und Content-Streams über den Flate-Pfad von HotPDF, sodass eine Schrift, deren ToUnicode-Stream einen ungewöhnlichen Filter verwendet, auf die nächste Strategie zurückfällt, statt die Seite scheitern zu lassen. In der Praxis deckt FlateDecode nahezu alles ab, was in den letzten zwei Jahrzehnten erzeugt wurde, und der Rückfall ist absichtlich still — Sie erhalten den besten Text, den die Datei zulässt, statt einer Exception. Dieselbe leseseitige Objektmechanik, die hier Schrift-Dictionaries auflöst, treibt auch das Bearbeiten von Metadaten in geladenen Dokumenten an, sodass eine Dokumenteingangs-Pipeline in einem Durchgang extrahieren, prüfen und annotieren kann

Textextraktion, layouterhaltende Darstellung, Zugriff auf Glyphenebene und die darauf aufbauenden Such- und Ersetzungsfunktionen sind alle Teil der Standardausgabe von HotPDF Delphi Component für Delphi und C++Builder — keine externen DLLs, keine Textdienste des Betriebssystems, nur Object Pascal, durch das Sie schrittweise debuggen können, wenn eine seltsame Datei in Ihrer Warteschlange landet