Technischer Artikel

Strukturierte PDF-Textextraktion in Delphi (PDFium VCL)

PDFiumPas liefert Seitentext als Struktur statt als String zurück. GetStructuredText erzeugt eine TPdfStructuredTextPage, die Blöcke enthält, jeder mit Zeilen, jede mit formatierten Spans, mit seitenraumbezogenen Grenzen auf jeder Ebene und erhaltenen Quellzeichenindizes, sodass sich jedes Fragment auf die zugrunde liegende Textseite zurückführen lässt

Die einfache String-Extraktion, mit der die meisten Codebasen beginnen, gibt es weiterhin, und sie ist für ihren Zweck weiterhin korrekt. Sie hört auf zu genügen, sobald man wissen muss, welche Wörter eine Überschrift waren, welche zur linken Spalte gehörten oder wo auf der Seite ein Treffer tatsächlich sitzt

Warum ist ein flacher String die falsche Ausgabe für die meisten Aufgaben?

Weil die Fragen, die man an extrahierten Text stellt, fast nie "welche Zeichen stehen auf dieser Seite" lauten. Sie lauten "was ist der Titel", "ist das eine Tabelle", "gehört dieser Absatz zu Abschnitt 4", "wo zeichne ich die Hervorhebung". Ein einzelner String beantwortet keine davon, und jede Antwort, die man aus ihm rekonstruiert, ist eine Heuristik, die man nun selbst besitzt

Zweispaltige Layouts machen den Punkt konkret. Extrahiert man einen zweispaltigen Artikel als String, erhält man, je nachdem, wie der Erzeuger den Content-Stream geschrieben hat, entweder Spalte eins gefolgt von Spalte zwei, oder man erhält Zeile eins von Spalte eins, Zeile eins von Spalte zwei, Zeile zwei von Spalte eins, und so weiter die Seite hinunter. Beides kommt aus einem konformen PDF. Keines ist auf Formatebene falsch, weil PDF Markierungen auf einer Seite beschreibt, nicht eine Dokumentgliederung. Ein blockbasiertes Modell lässt den Extraktor die Reihenfolgeentscheidung explizit treffen und mitteilen, welche Entscheidung er getroffen hat

Inhaltsreihenfolge oder physisches Layout?

TPdfStructuredTextOptions.ReadingOrder wählt zwischen roContentOrder und roPhysicalLayout, und die richtige Antwort hängt davon ab, wem man mehr vertraut, dem Erzeuger oder der Geometrie

Content Order liefert Text in der Reihenfolge zurück, in der der Content-Stream ihn zeichnet. Das ist schnell und für Dokumente eines wohlerzogenen Erzeugers meist die beabsichtigte Lesereihenfolge. Physical Layout ignoriert die Stream-Reihenfolge und rekonstruiert die Reihenfolge aus der tatsächlichen Position der Zeichen, gruppiert in Zeilen und dann in Spalten. Das will man bei gescannten und dann per OCR erfassten Seiten, bei Ausgaben von Tools, die Text in Schriftreihenfolge statt in Lesereihenfolge ausgeben, und bei allem, wo das visuelle Ergebnis das Einzige ist, worauf man sich verlassen kann

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 1-basiert

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // fail-closed Budget

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

Was fügt Tagging hinzu, das Geometrie nicht kann?

Absicht. Bei aktiviertem IncludeSemantics tragen Blöcke aus einem getaggten PDF ein Kind aus dem Strukturbaum, sodass eine Überschrift eine Überschrift ist, weil der Erzeuger es so gesagt hat, nicht weil ihre Schrift größer als der Durchschnitt war. Die Arten decken die Formen ab, die für Weiterverwendung zählen: cfParagraph, cfHeading mit einem HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure und der ungetaggte Fallback cfPlain

Das Feld Source erfasst, woher jede Klassifizierung stammt, rosStructure für den Strukturbaum und rosHeuristic für Inferenz, das Feld, das man protokollieren sollte, wenn man entscheidet, wie weit man einer Extraktions-Pipeline über einen Dokumentbestand hinweg vertraut. Figuren sind ein besonderer Fall, den man kennen sollte: Bei einem cfFigure-Block stammt der Text aus der alternativen Beschreibung statt aus irgendwelchen Glyphen, da eine Figur keine eigenen Zeichen hat. Nicht zugeordneter Alternativtext wird weiterhin dargestellt, statt verworfen zu werden, was es einem Barrierefreiheits-Audit erlaubt zu sehen, dass eine Beschreibung existiert, selbst wenn nichts auf der Seite sie zeichnet. Das Tagging-Modell selbst wird in PDF/UA-Strukturbaum-Validierung behandelt

Spans tragen die Formatierung und die Herkunft

Jeder TPdfStructuredTextSpan trägt seinen Text, seine seitenraumbezogenen Grenzen, FontName, FontSize, FontWeight und Angle, plus SourceStartIndex und SourceCharacterCount. Spans brechen dort, wo sich die Formatierung ändert, sodass ein Satz mit drei fetten Wörtern zu drei Spans wird, und Hervorhebung in HTML oder Markdown wiederherzustellen ist eine Frage des Lesens von Eigenschaften statt des Ratens aus Schriftnamen

Die beiden Quellindex-Felder sind diejenigen, die aus Extraktion ein Feature statt eines Berichts machen. Sie zeigen zurück in die Zeichenfolge der Seite, was bedeutet, dass ein in einer Suche gefundener Block ohne einen zweiten, anders geordneten Durchlauf über den Text in zeichengenaue Auswahlgeometrie oder ein Highlight-Rechteck umgewandelt werden kann; die Mechanik wird in visueller Textzeilenauswahl mit Character Boxes beschrieben. Das Feld Angle zählt mehr, als es aussieht: Gedrehter Text in einem Stempel oder Wasserzeichen landet im selben Koordinatenraum wie Fließtext, und eine Pipeline, die Winkel ignoriert, verschmilzt bereitwillig ein diagonales "ENTWURF" mitten in einen Absatz

Budget, und die zwei Qualitätszähler

MaxCharacters ist ein fail-closed Budget, keine Abschneide-Einstellung: Eine Seite, die es überschreitet, stoppt, statt still einen Teil des Inhalts zurückzugeben. Auf einem nicht vertrauenswürdigen Aufnahmepfad ist das das gewünschte Verhalten, weil eine Seite mit einer Million Zeichen entweder ein maschinell erzeugtes Monstrum oder ein Versuch ist, den eigenen Extraktor zum langsamsten Teil des Systems zu machen

Zwei Zähler auf der zurückgegebenen Seite beschreiben die Extraktionsqualität direkt. UnmappedCharacterCount zählt Zeichen ohne brauchbare Unicode-Zuordnung, das klassische Symptom einer Subset-Schriftart, die ohne /ToUnicode-CMap eingebettet wurde; solcher Text rendert perfekt und extrahiert als nichts Brauchbares. GeometryFailureCount zählt Zeichen, deren Begrenzungsrahmen nicht bestimmt werden konnte, was die Physical-Layout-Reihenfolge verschlechtert. Protokollieren Sie beide. Ein Dokumentbestand, bei dem diese Zahlen konstant nahe null liegen, lässt sich mit Vertrauen indizieren, und einer, bei dem das nicht der Fall ist, sagt Ihnen, dass manche Erzeuger in Ihrer Pipeline Aufmerksamkeit brauchen, bevor irgendein nachgelagertes Ergebnis vertrauenswürdig ist

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

Performance auf echten Seiten

Physical-Layout-Extraktion ist der teure Modus, und die Implementierung ist für Seiten gebaut, die tatsächlich groß sind: Die Zeichenordnung läuft in O(n log n) statt durch wiederholtes Scannen, Zeilen- und Span-Puffer wachsen geometrisch statt bei jedem Zeichen neu zu allozieren, Unicode-Text wird in Puffern statt durch String-Konkatenation aufgebaut, und Schrift-Lookups für benachbarte Textobjekte werden gecacht. Diese Kombination hält eine dichte 5.000-Zeichen-Seite vorhersagbar statt quadratisch

Bei einem seitenzahllastigen Job lohnt es sich weiterhin, wo möglich den günstigeren Modus zu wählen. Nutzen Sie roContentOrder mit aktivierter Semantik für getaggte Dokumente, denen Sie vertrauen, und reservieren Sie roPhysicalLayout für gescanntes und veraltetes Material, bei dem Geometrie das einzige Signal ist. Braucht man nur einen einfachen String, bleibt die in Extrahieren von Text aus PDF-Dokumenten beschriebene einfachere API der schnellere Weg, und wenn man Text auf Marked-Content-Kennungen zurückführen muss, deckt Lesen und Schreiben von BDC- und MCID-Marked-Content diese Ebene ab

Das Blockmodell passt auch sauber auf das, was Retrieval-Pipelines wollen: eine Überschrift mit ihren Absätzen ist ein Chunk mit einem Titel, und die Grenzen lassen ein Zitat auf eine Position auf einer Seite zeigen statt auf ein ganzes Dokument. PDFiumPas ist eine Delphi- und Lazarus-Komponente rund um die PDFium-Engine, dokumentiert mit Beispielen auf der PDFium Delphi-Komponente-Seite