Technischer Artikel

Eine durchsuchbare Textebene zu gescannten PDFs in Delphi hinzufügen

PDFium Component fügt gescannten PDF-Seiten von Delphi aus über ApplyOcrSearchLayer eine durchsuchbare Textebene hinzu. Es rendert jede ausgewählte Seite, übergibt die Pixel an einen selbst bereitgestellten OCR-Provider und schreibt die erkannten Wörter als unsichtbare Textobjekte zurück, positioniert über den Wörtern im Scan. Das ursprüngliche Seitenbild wird dabei nie dekodiert, neu kodiert oder ersetzt, sodass das visuelle Ergebnis byteidentisch der Ausgangsseite entspricht

Die Erkennungs-Engine ist bewusst nicht Teil der Bibliothek. PDFium stellt Seitenrendering, Koordinatenabbildung, Schriftladen, Textobjekterstellung und unsichtbare Rendermodi bereit, enthält aber keine OCR-Engine, und das Gegenteil vorzutäuschen würde bedeuten, das Erkennungsprodukt eines Dritten in eine PDF-Komponente zu bündeln. Stattdessen lebt die Erkennung hinter der Schnittstelle IPdfOcrProvider: Die Bibliothek übergibt BGRA-Pixel mit festem Layout und Ursprung oben, und der Provider gibt Unicode-Text, Konfidenzwerte und Wort-Vierecke zurück

Was genau ist eine durchsuchbare Textebene?

Ein gescanntes PDF ist ein Bild eines Dokuments. Der Seiteninhalt ist ein einziges großes Bild, und es gibt nichts zu markieren, zu durchsuchen, zu kopieren oder zu indizieren. Eine durchsuchbare Textebene fügt echte Textobjekte über diesem Bild hinzu, mit auf unsichtbar gesetztem Rendermodus, sodass Viewer nichts zeichnen, während Markierung, Suche und Extraktion die Wörter genau dort finden, wo sie erscheinen

Positionierung ist das Ganze. Sitzt der unsichtbare Text nur wenige Punkte daneben, landen Markierungs-Hervorhebungen neben statt auf den Wörtern, und das Kopieren eines Absatzes erzeugt Text in falscher Reihenfolge. Deshalb muss die Geometrie aus denselben Transformationen stammen, die PDFium zum Rendern der Seite verwendet, statt aus einer proportionalen Schätzung

Den Provider implementieren

Der Provider-Vertrag besteht aus einer Methode. Sie erhält einen Seitenbild-Datensatz mit Abmessungen, Stride, DPI, Pixelformat und den Pixel-Bytes selbst, dazu ein Abbruch-Token, und gibt Wörter oder eine Fehlermeldung zurück:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels enthält BGRA-Zeilen mit Ursprung oben und Image.Stride Bytes.
  // An die eigene Engine übergeben, dann einen Eintrag pro erkanntem Wort füllen
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // 0..1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

Vierecke statt Rechtecke, weil ein Scan selten seitengerecht ausgerichtet ist. Ein Wort auf einer leicht gedrehten Seite belegt ein Parallelogramm, und TPdfOcrQuad trägt vier Eckpunkte, sodass schräge und gedrehte Wörter eine genaue Auswahlregion behalten. Engines, die nur achsenparallele Rechtecke melden, können FromRectangle nutzen, das das entartete Viereck aufbaut

Warum lassen sich Wortpositionen nicht proportional skalieren?

Es ist verlockend, eine Pixelkoordinate in eine Seitenkoordinate umzurechnen, indem man durch die Renderbreite teilt und mit der Seitenbreite multipliziert. Das funktioniert nur bei Seiten ohne Rotation, mit einer CropBox identisch zur MediaBox und einem Ursprung bei null, und zahlreiche gescannte Dokumente verfehlen mindestens eine dieser Bedingungen

PDFium Component bildet jede der vier Viereck-Ecken einzeln über FPDF_DeviceToPage ab, dieselbe Abbildung, die der Renderer zur Erzeugung der Pixel verwendet hat, sodass /Rotate-Einträge und versetzte Crop-Boxen konstruktionsbedingt korrekt behandelt werden. Die affine Matrix für das Textobjekt wird dann aus drei der abgebildeten Punkte gebaut, der unteren linken, unteren rechten und oberen linken Ecke, was genau ausreicht, um Position, Skalierung, Rotation und Scherung auszudrücken

Das Textobjekt selbst wird mit Einheitsschriftgröße erstellt, damit seine tatsächlichen Schriftgrenzen gemessen werden können, und die gemessenen Objektgrenzen werden anschließend auf das Zielviereck abgebildet. Die Größe anhand einer geschätzten Punktgröße festzulegen und zu hoffen, dass sie zum gescannten Wort passt, würde mit jedem Schriftaustausch driften; erst zu messen macht die Passform unabhängig davon, welche Schrift die Ebene verwendet

Über ein Dokument laufen lassen

Der Options-Datensatz steuert Auflösung, Filterung und jedes Budget. Die Konfidenzfilterung ist wichtiger, als sie wirkt: Datenmüll-Wörter mit niedriger Konfidenz verschmutzen Suchergebnisse dauerhaft, und anders als bei einem falschen Rendering bemerkt es niemand, bis eine Suche Unsinn zurückliefert:

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // Erkennungsauflösung
    Options.MinConfidence := 0.60;       // unsichere Wörter verwerfen
    Options.SkipPagesWithText := True;   // born-digital Seiten unangetastet lassen
    Options.ContinueOnError := True;     // eine fehlerhafte Seite darf den Job nicht stoppen
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText verdient in gemischten Archiven besondere Beachtung. Ein PDF, das bereits echten Text trägt, ob born-digital oder zuvor verarbeitet, bekommt eine zweite Textebene, wenn man OCR blind darüber laufen lässt, und das Duplikat lässt die Extraktion jedes Wort zweimal liefern. Der Seitenstatus popsSkippedExistingText zeigt genau, welche Seiten unangetastet blieben

Budgets, Abbruch und Fehlereindämmung

Jede Größe, die ein feindseliges oder schlicht riesiges Dokument aufblähen kann, hat eine Obergrenze: Pixel pro Seite und insgesamt, Wörter pro Seite und insgesamt sowie Zeichen pro Wort. Alle werden vor dem Schreiben der Seite geprüft, nicht danach, und die Pixelschätzung wird aus Seitenabmessungen und DPI berechnet, bevor überhaupt eine Bitmap zugewiesen wird. Eine Erhöhung der DPI von 150 auf 300 vervierfacht den Speicher pro Seite, daher ist die Obergrenze pro Seite der Parameter, der zuerst angepasst werden sollte, wenn ein Batch-Job bei großen Formaten zu scheitern beginnt

Das Abbruch-Token zieht sich durch den gesamten Pfad: fortschreitendes Rendering, den Provider-Aufruf und die Einfügeschleife pro Wort. Das bedeutet, ein Nutzer, der während der Erkennung einer 400-seitigen Datei abbricht, stoppt innerhalb einer Seite statt am Ende des Dokuments, und dasselbe an anderer Stelle in der Komponente verwendete Token-Muster, beschrieben in abbrechbarem fortschreitendem Rendering, gilt hier unverändert

Fehlereindämmung erfolgt pro Seite. Die Bibliothek sammelt die auf einer Seite eingefügten Objekt-Handles und ruft FPDFPage_GenerateContent einmal auf, nachdem alle Wörter platziert wurden. Schlägt mittendrin etwas fehl, ob ein Provider-Fehler oder ein Schriftproblem, werden die auf dieser Seite eingefügten Objekte in umgekehrter Reihenfolge entfernt und der Seiteninhalt neu erzeugt, sodass eine fehlgeschlagene Seite in ihren ursprünglichen Zustand zurückkehrt, statt eine halbe Textebene zu behalten. Die Dokumentschleife läuft dann gemäß ContinueOnError weiter oder stoppt, und die aktive Seite wird immer wiederhergestellt

Prüfen, ob das Bild wirklich unangetastet blieb

Die stärkste verfügbare Prüfung ist zugleich die einfachste: die Seite vor und nach dem Anwenden der Ebene in derselben Größe rendern und die Bitmaps vergleichen. Sie sollten byteidentisch sein, weil unsichtbarer Text nichts zeichnet und der Bildstream nie dekodiert wurde. Jede Abweichung bedeutet, dass etwas anderes als die Textebene die Seite verändert hat

Danach die Textseite prüfen, indem man aus der verarbeiteten Datei extrahiert und bestätigt, dass Wortpositionen auf dem Scan landen. Der Extraktionspfad ist derselbe, der in Text aus PDF-Dokumenten extrahieren beschrieben wird, und für eine schnelle visuelle Prüfung der Ausrichtung erlaubt das Rendern von Seiten zu Bildern wie in PDF-Seiten in JPEG umwandeln, Wort-Boxen über dem Scan zu überlagern

OCR-Ebenen, Rendering, Extraktion und Bearbeitung laufen alle gegen dasselbe Dokumentobjekt in Delphi, C++Builder und Lazarus; die vollständige API-Oberfläche wird auf der PDFium-Component-für-Delphi-Seite beschrieben