Technischer Artikel

Bilder aus einer geladenen PDF-Datei in Delphi extrahieren: HotPDF

Sie haben eine PDF-Datei auf der Festplatte, die ein Kunde von einem Stapel Rechnungen gescannt hat, und Ihre Aufgabe besteht darin, die Seitenbilder als Bitmaps für einen OCR-Durchlauf wieder herauszuziehen. Sie laden die Datei, Sie finden die Image-XObjects und entdecken dann den Teil, vor dem Sie niemand gewarnt hat: Die Bytes in diesen Streams are keine Pixel. Es handelt sich um einen JPEG-Codestream, einen Wavelet-komprimierten JPEG-2000-Blob, eine Group-4-Fax-Sequenz oder ein indiziertes Raster hinter einer Palette und einem Flate-Filter. Das Bildobjekt kennt seine Breite und Höhe, aber die tatsächlichen Samples sind in dem Filter eingeschlossen, den der Ersteller gewählt hat. Um ein brauchbares TBitmap zu erhalten, muss dieser Filter rückgängig gemacht werden, und PDF bietet Ihnen ungefähr acht verschiedene Möglichkeiten, wie die Bytes versiegelt sein können

Dies ist die Lücke, die ExtractLoadedImage in HotPDF schließt, der nativen VCL-PDF-Komponente für Delphi und C++Builder. Sie listet die Image-XObjects in einem von Ihnen geladenen Dokument auf, meldet, worum es sich bei jedem handelt, und dekodiert diejenigen, die sie kann, zurück in eine 24-Bit-Bitmap. Das Interessante daran ist nicht die API-Oberfläche, die aus drei Methoden besteht. Es ist die Frage, warum überhaupt ein separater Dekodierungspfad existieren muss und was er in Pixel zurückverwandeln kann und was nicht

Warum geladene Bilder nicht bereits dekodiert sind

Der Loader von HotPDF ist auf Pass-Through-Treue ausgelegt. Wenn Sie LoadFromFile aufrufen, bleiben die Bildströme genau so erhalten, wie sie in der Quelldatei erscheinen: der ursprüngliche Filter, die ursprünglich komprimierten Bytes, das ursprüngliche Dictionary. Das ist Absicht. Der Sinn des Ladens eines Dokuments besteht meist darin, Seiten zu kopieren, Dateien zusammenzuführen, sie mit Stempeln zu versehen, Zugriffsrechte neu zu vergeben und sie wieder auszuschreiben. Für all das ist es am effizientesten und sichersten, jeden Bildstrom unberührt zu lassen. Das Dekodieren jedes Bildes in ein Raster beim Laden würde bei Arbeit, die die meisten Aufrufer nie benötigen, Speicher und CPU beanspruchen, und das erneute Kodieren beim Speichern würde die Qualität von Bildern verschlechtern, die unverändert hätten kopiert werden sollen

Die Folge ist, dass der geladene Objektgraph keine Pixel enthält. Ein Image-XObject, dessen /Filter /DCTDecode ist, enthält JPEG-Bytes. HotPDF hat niemals einen JPEG-Decoder darauf angewendet, da auf dem Kopier- und Umschreibepfad nichts dies erforderte. Wenn Sie also tatsächlich Pixel benötigen, muss die Extraktions-API die Dekodierung selbst von Grund auf für den Filter durchführen, den das jeweilige Bild verwendet. Dies ist derselbe Grund, warum die kodierungsseitigen Codecs unabhängig vom Loader sind: Der Artikel über das Hinzufügen von JPEG 2000-Bildern zu PDFs in Delphi beschreibt, wie die JPX-Engine in die Erstellungsseite eingebunden wird, und diese Engine war schlicht nicht in den Lesepfad integriert, bis die Extraktions-API sie benötigte

Die Drei-Methoden-API

Die Oberfläche ist klein. GetLoadedImageCount gibt zurück, wie viele Image-XObjects das geladene Dokument enthält. GetLoadedImageInfo füllt einen Deskriptordatensatz für eines davon nach Index. ExtractLoadedImage gibt die dekodierte Bitmap zurück, oder nil, wenn das Bild nicht dekodiert werden kann. Die Auflistung ist indexbasiert und für einen bestimmten Ladevorgang stabil: Intern durchläuft sie die Tabelle der indirekten Objekte und sammelt jeden Stream, dessen /Subtype zu /Image aufgelöst wird, sodass der Index, den Sie an GetLoadedImageInfo übergeben, derselbe Index ist, den Sie an ExtractLoadedImage übergeben

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Zwei Vertragsdetails sind hier wichtig. Erstens liegt die Freigabe des zurückgegebenen TBitmap in Ihrer Verantwortung; das Dokument speichert oder besitzt es nicht. Zweitens sollten Sie Decodable vor dem Aufruf und das Ergebnis danach auf nil prüfen. Die Methode löst bei einem nicht unterstützten Filter keine Ausnahme aus, sondern gibt nil zurück, und ein unbemerktes nil in einer Batch-Schleife ist genau das, was eine Seite aus einem Auftrag mit tausend Seiten verschlucken kann, ohne dass es jemand bemerkt

Lesen des Deskriptors vor dem Dekodieren

THPDFLoadedImageInfo sagt Ihnen, worum es sich bei einem Bild handelt, ohne eine vollständige Dekodierung durchzuführen. Seine Felder stammen direkt aus dem Bild-Dictionary: Width und Height in Samples, BitsPerComponent, ColorComponents und ColorSpace, die die Interpretation nach dem Dekodieren beschreiben (1 für Grau, 3 für RGB, 4 für CMYK), Filter als benannte Komprimierung, IsImageMask für Schablonenmasken, ObjectNumber für das zugrunde liegende indirekte Objekt und Decodable

Dieses letzte Flag ist das entscheidende. Decodable ist nur dann True, wenn der aktuelle Build diese spezielle Filter- und Farbraumkombination tatsächlich in eine Bitmap umwandeln kann. Es kodiert die tatsächliche Support-Matrix, nicht einen Wunsch: Ein Bild, dessen Filter vom aktuellen Build nicht verstanden wird, meldet Decodable = False, und Sie können darauf basierend verzweigen, um das Ereignis zu protokollieren, zu überspringen oder darauf zurückzugreifen, den rohen Stream selbst zu extrahieren. Behandeln Sie es als Vorbedingung, nicht als Hinweis

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

Ein Implementierungsdetail macht Entwicklern zu schaffen, die Deskriptordatensätze manuell erstellen. THPDFLoadedImageInfo enthält zwei AnsiString-Felder, Filter und ColorSpace. Dies sind verwaltete Typen mit Referenzzählung, sodass der Reflex, einen Datensatz mit FillChar(Info, SizeOf(Info), 0) auf Null zu setzen, hier falsch ist: Er überschreibt die String-Referenz, ohne sie zu dekrementieren, was zu Lecks oder Datenkorruption führt. HotPDF initialisiert den Datensatz genau aus diesem Grund Feld für Feld. Wenn Sie dieses Muster in Ihrem eigenen Code kopieren, sollten Sie das gleiche tun

Ein dispatcher, acht Filterpfade

Der Grund, warum diese Funktion eine Reihe von Releases anstelle von nur einer benötigte, liegt darin, dass PDF kein eigenes Bildformat hat. Es verfügt über Filter, und §8.9.5 von ISO 32000-1 erlaubt es einem Image-XObject, jeden beliebigen davon in /Filter anzugeben, wobei die Interpretation der Samples separat durch /ColorSpace, /BitsPerComponent und ein optionales /Decode-Array geregelt wird. ExtractLoadedImage liest den Filternamen und leitet den Aufruf an einen dedizierten Decoder für jeden Fall weiter. Das unterstützte Set, das von v2.229 bis v2.231 aufgebaut wurde, deckt nun acht verschiedene Pfade ab

  • Rohe Raster (FlateDecode, LZWDecode oder kein Filter) in 8-Bit DeviceRGB oder DeviceGray. Die Bytes werden zu einem gepackten Raster aufgebläht, und die einzige Transformation ist ein Kanaltausch, der weiter unten behandelt wird
  • DCTDecode (JPEG). Der Codestream wird an das TJPEGImage der VCL übergeben, das Geometrie und Farbe auflöst, und das Ergebnis wird einer 24-Bit-Bitmap zugewiesen
  • JPXDecode (JPEG 2000). Dekodiert über das OpenJPEG-Backend, dieselbe Engine, die im JPEG 2000-Artikel beschrieben wird, wobei Komponenten mit hoher Bittiefe auf 8 Bit herabgestuft werden
  • Indizierte Farbe (Indexed Color). Die Palette wird aus dem Array [/Indexed base hival lookup] gelesen, und jedes Sample wird über die Nachschlagetabelle zu Echtfarben expandiert
  • DeviceCMYK. Vierkanal-Samples werden mit der Standardformel (Tinte auf Weiß) in RGB umgerechnet
  • Sub-8-Bit DeviceGray und indiziert mit 1, 2 oder 4 Bit pro Komponente, Sample für Sample entpackt und auf den Bereich 0–255 skaliert
  • CCITTFaxDecode, die Faxfilter Group 3 und Group 4, dekodiert von einem dedizierten T.4/T.6-Backend
  • JBIG2Decode, der Bilevel-Filter mit hoher Kompressionsrate, dekodiert über das registrierte JBIG2-Backend, das der Artikel über native JBIG2-Komprimierung auf der Kodierungsseite behandelt

Alles landet am selben Ort: einer 24-Bit-BGR-Bitmap, da dies das native Format ist, das ein VCL-TBitmap speichert, und das, was jeder nachgeschaltete Empfänger erwartet

Die Transformationen, die Pixel unbemerkt verändern

Zwei dieser Pfade beinhalten eine Transformation, die leicht im Detail fehlerhaft sein kann und die man verstehen sollte, auch wenn man den Decoder nie selbst anfasst. Die erste ist der Farbreihenfolge-Tausch. Ein PDF-DeviceRGB-Raster speichert Samples in der Reihenfolge Rot-Grün-Blau, beginnend mit der obersten Zeile. Eine 24-Bit-Scanline der VCL speichert sie in der Reihenfolge Blau-Grün-Rot. Das Dekodieren eines einfachen RGB-Bildes ist also kein reiner Kopiervorgang im Speicher (memcpy); das erste und dritte Byte jedes Pixels werden auf dem Weg in die Scanline vertauscht. Wenn Sie das verwechseln, tauschen Rot und Blau ihre Plätze, was auf einem Graustufen-Testbild in Ordnung aussieht, auf einem Farbbild jedoch katastrophal falsch ist. Die Zeilenreihenfolge wird eins zu eins abgebildet: Die Top-Down-Raster von PDF stimmen mit der VCL-ScanLine[0] als oberster visueller Zeile überein, sodass kein vertikales Spiegeln erforderlich ist

Die zweite ist CMYK. PDF-DeviceCMYK-Bilder übertragen vier Druckfarben, und die Umwandlung in RGB ist eine Berechnung pro Kanal, keine Nachschlagetabelle: Jeder Ausgangskanal berechnet sich als (255 - Tinte) * (255 - K) / 255. Dies ist eine Geräteannäherung, keine farbverwaltete Konvertierung über ein ICC-Profil. Das Ergebnis ist also für die Anzeige und die Re-Rasterung genau genug, aber nicht der richtige Weg, wenn Sie druckgenaue Farben benötigen. Wenn Ihr Arbeitsablauf höchste Treue erfordert, behandeln Sie die extrahierte Bitmap als Vorschau und behalten Sie den ursprünglichen CMYK-Stream für den farbverwalteten Workflow

Der Indexed-Pfad birgt eine eigene Parsing-Falle. Die Palette in einem indizierten (/Indexed) Farbraum kann als Literal-String oder als Hexadezimal-String gespeichert werden, und HotPDF speichert den Wert eines Hex-Strings als Hex-Text, nicht als dekodierte Bytes. Wenn die Palette also ein Hex-String ist, muss die Nachschlagetabelle zuerst eine Hex-zu-Bytes-Dekodierung durchlaufen; ein Literal-String besteht bereits aus rohen Bytes. Wenn Sie diesen Zweig verpassen, wird ein indiziertes Bild mit vier Farben als Müll ausgegeben, da jeder Paletteneintrag an der falschen Byte-Grenze gelesen wird

Filterketten: Der letzte Filter ist der des Bildes selbst

Ein einzelnder /Filter-Name ist der einfache Fall. PDF erlaubt auch eine Kette von Filtern, bei der der Stream nacheinander mehrere Filter durchlaufen hat, die in einem /Filter-Array der Reihe nach aufgelistet sind, wie zum Beispiel [/ASCII85Decode /FlateDecode] oder [/ASCIIHexDecode /DCTDecode] (ISO 32000-1 §7.4). Die Semantik ist präzise: Die Filter werden beim Kodieren von links nach rechts angewendet, sodass Sie sie beim Dekodieren von rechts nach links rückgängig machen. Der letzte Filter im Array ist derjenige, der tatsächlich das Bildformat definiert. Die davor liegenden Filter sind lediglich Transportkodierungen, die darum gewickelt sind

Der Extraktor handhabt dies durch Schälen (Peeling). Bevor ein Bild-Decoder ausgeführt wird, wird jeder Filter in der Kette mit Ausnahme des letzten angewendet, um die Eingabe zu erzeugen, die der letzte Filter erwartet. Erst danach erfolgt die Weiterleitung an diesen letzten Filter. So wandelt [/ASCII85Decode /DCTDecode] den Stream zuerst aus ASCII85 zurück und leitet das Ergebnis dann an den JPEG-Pfad weiter; [/FlateDecode] um ein rohes Raster herum bläht den Stream auf und führt dann den Raster-Pfad aus. Dadurch können die acht Decoder einfach bleiben. Keiner von ihnen muss etwas über ASCII85- oder Hex-Transport-Wrapper wissen, da diese bereits entfernt sind, wenn ein Decoder die Bytes sieht. Es bedeutet auch, dass eine Kette, deren letzter Filter nicht unterstützt wird, sauber beim Dispatch-Schritt fehlschlägt und nicht erst auf halbem Weg

Wo die Extraktion aufhört und was man dann tun kann

Seien Sie ehrlich zu sich selbst bezüglich der Grenzen. Ein Bild, dessen letzter Filter außerhalb des unterstützten Sets liegt, gibt nil zurück, ebenso wie eines, dessen Farbraum der Build nicht interpretieren kann. Soft-Masks und Alpha werden in der Bitmap nicht rekonstruiert; Sie erhalten das Basisbild, kein zusammengesetztes Ergebnis. Bittiefen über 8 von JPEG 2000 werden heruntergerechnet, was Absicht ist und zu Verlusten führt. Dies ist der falsche Weg, wenn Sie archivieren statt nur anzeigen möchten. Und eine Bildmaske (Image Mask), eine Ein-Bit-Schablone ohne eigene Farbe, wird zwar vom Deskriptor beschrieben, ist aber etwas anderes als ein normales Bild; wenn Sie sie in der Erwartung eines Fotos dekodieren, werden Sie überrascht sein

Wenn die Extraktion nicht ausreicht, befindet sich der rohe Stream immer noch direkt im geladenen Objektgraphen, samt Filter, und Sie können ihn Byte für Byte herausziehen und an einen eigenen spezialisierten Codec übergeben. Das ist der Fallback, den das Pass-Through-Design bewusst bewahrt: Die Original-Bytes werden niemals weggeworfen. Im schlimmsten Fall dekodieren Sie sie also selbst, anstatt dass die Daten verloren sind. Für die meisten realen Aufgaben decken die unterstützten acht Filter jedoch das ab, was Scanner, Office-Suiten und Berichts-Engines tatsächlich ausgeben, und eine Schleife über GetLoadedImageCount mit einer Decodable-Prüfung verwandelt eine geladene PDF-Datei in wenigen Zeilen in einen Ordner voller Bitmaps

Die hier gezeigte Extraktions-API für geladene Bilder wird zusammen mit dem vollständigen Satz der hier beschriebenen Dekodierungsfilter in der HotPDF Component für Delphi und C++Builder ausgeliefert