Technischer Artikel

WebP nach PDF in Delphi: HotPDFs VP8L-Decoder

HotPDF 2.747.0 dekodiert WebP-Bilder mit einem von Grund auf in Object Pascal geschriebenen VP8L-Decoder (WebP lossless). Dadurch akzeptiert THotPDF.AddImageFromFile einen .webp-Pfad direkt, ohne auszuliefernde libwebp-DLL und ohne zu startenden Hilfsprozess. Der Decoder implementiert RFC 9649 Abschnitt 3 vollständig: den RIFF-Containerdurchlauf, kanonische Präfixcodes, LZ77-Rückwärtsreferenzen, den Color Cache und alle vier inversen Transformationen. Verlustbehaftete VP8-Frames werden ausdrücklich zurückgewiesen, statt halb dekodiert zu werden

Der Auslöser war alltäglich. Ein Designtool exportiert jedes Asset als WebP, weil das heute der Standard ist; die Assets landen in einem Rechnungs- oder Kataloggenerator, der seit einem Jahrzehnt problemlos PNG und JPEG verarbeitet, und plötzlich wird die Hälfte der Eingaben abgelehnt. Die offensichtliche Lösung ist, libwebp zu binden und weiterzumachen. Genau diese offensichtliche Lösung verwandelt aber eine in sich geschlossene VCL-Komponente in ein Produkt mit einer zusätzlichen Deployment-Geschichte

Warum VP8L implementieren statt libwebp zu binden?

HotPDF implementiert den Codec in Pascal, weil eine Delphi-Komponente, die Kunden in ihre eigene ausführbare Datei kompilieren, nicht unbemerkt eine Laufzeit-DLL benötigen sollte. Eine native Abhängigkeit bedeutet eine 32- und eine 64-Bit-Binärdatei zur Pflege, eine festzulegende Version, eine zu erklärende Codesigning-Kette und eine weitere Datei, die Antivirensoftware auf einem gesperrten Terminal ablehnen kann. Für eine Komponente, die sich in ein Projekt legen lässt und dann funktioniert, ist das ein realer und kein theoretischer Preis. Die andere Hälfte des Arguments ist, dass VP8L klein ist: ein Präfixcode-plus-LZ77-Format mit vier inversen Transformationen und einer Nachbarschaftsabstandstabelle mit 120 Einträgen; der gesamte Decoder in HPDFWebP.pas umfasst weniger als 900 Pascal-Zeilen. In THotPDF.AddImage sitzt der WebP-Zweig an derselben Extension-Dispatch-Stelle, die bereits .jp2, .j2k, .jpt und .jpc an den JPEG-2000-Pfad weiterleitet – genau dort, wo auch der Leitfaden zum Hinzufügen von JPEG-2000-Bildern zu PDFs in Delphi ansetzt. Aufrufer, die rohe Pixel statt eines PDF-Bildes benötigen, können direkt HPDFDecodeWebPLossless verwenden. Die Funktion füllt ein TWebPCardinalArray mit $AARRGGBB-Werten in Scanline-Reihenfolge

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp wird an den integrierten VP8L-Decoder weitergeleitet, ohne DLL
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Warum liest sich ein VP8L-Bitstream gleichzeitig in zwei Richtungen?

Weil Container-Bitordnung und Bitordnung der Präfixcodes unabhängig voneinander spezifiziert sind und VP8L dafür entgegengesetzte Konventionen verwendet. RFC 9649 Abschnitt 3.2 sagt ausdrücklich, dass der Bitstream least-significant-bit first gelesen wird: Der Leser beginnt bei Bit 0 eines Bytes und läuft aufwärts. Die darin enthaltenen kanonischen Präfixcodes kommen most-significant-bit first, also mit der Baumwurzel zuerst; der Decodierdurchlauf schiebt seinen Akkumulator daher nach links und setzt jedes neue Bit unten ein. Leser und Code-Durchlauf laufen in derselben Schleife also in entgegengesetzte Richtungen, was beim erneuten Lesen jedes Mal wie ein Fehler aussieht

function TWebPBitReader.ReadBit: Integer;
begin
  if BytePos >= Length(Data) then
    raise EWebPDecode.Create('WebP bitstream exhausted');
  Result := (Data[BytePos] shr BitPos) and 1;   // LSB first, RFC 9649 3.2
  Inc(BitPos);
  if BitPos = 8 then
  begin
    BitPos := 0;
    Inc(BytePos);
  end;
end;

// Der kanonische Durchlauf geht andersherum: Das erste Bit aus dem Stream
// ist das höchstwertige Bit des Codes
for Len := 1 to 15 do
begin
  Code := (Code shl 1) or BR.ReadBit;
  if Counts[Len] > 0 then
  begin
    if Code - First < Counts[Len] then
      Exit(Symbols[Index + Code - First]);
    First := (First + Counts[Len]) shl 1;
    Index := Index + Counts[Len];
  end
  else
    First := First shl 1;
end;

Drei RFC-Details, die den Stream unbemerkt aus dem Takt bringen

Drei Semantiken in RFC 9649 werden jeweils genau einmal festgelegt, sind leicht zu überlesen, und jede kostet oder spart ein einziges Bit – genug, um jede spätere Tabelle in Rauschen zu verwandeln. Alle drei wurden im HotPDF-VP8L-Decoder gefunden, und alle drei erzeugen dasselbe Symptom: ein plausibel aussehendes Bild, das überall falsch ist

  • Ein entropiekodiertes Bild in einer nicht primären Rolle schreibt überhaupt kein Meta-Präfixbit. Die ABNF für entropy-coded-image enthält dieses Element schlicht nicht, daher bringt das Lesen eines Bits den Stream um eins aus dem Takt. HotPDF übergibt AllowMeta = False für das Entropiebild selbst, für Predictor- und Color-Transform-Daten sowie für die Palette der Farbindexierung
  • Ein Präfixcode mit einem einzigen Blatt verbraucht null Bits. RFC 9649 Abschnitt 3.7.2.1 sagt das direkt, und der kanonische Durchlauf würde bereitwillig ein Bit lesen und es anschließend nicht platzieren können. Daher erkennt BuildHuff eine Gesamtsymbolzahl von 1, markiert den Baum als Single und dekodiert dieses eine Symbol, ohne den Reader zu berühren
  • Ein Wert von cache_bits gleich 0 bedeutet eine Color-Cache-Größe von 0 und nicht 1 shl 0. Der bequeme Shift ergibt 1, wodurch das grüne Alphabet 256 + 24 + CacheSize zu 281 statt 280 wird und jede danach gelesene Präfixcodetabelle falsch ausgerichtet ist
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 bedeutet wirklich keine Einträge
if BR.ReadBit = 1 then
begin
  CacheBits := Integer(BR.ReadBits(4));
  if (CacheBits < 1) or (CacheBits > 11) then
    raise EWebPDecode.Create('WebP color cache bits out of range');
  CacheSize := 1 shl CacheBits;
end;

// RFC 9649 3.8.3: Nur das räumlich kodierte (ARGB-)Bild trägt das
// Meta-Präfixbit; entropiekodierte Rollen schreiben es nie
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

// ...
ReadHuffCode(256 + 24 + CacheSize, Groups[I].Green);   // 280, nicht 281
ReadHuffCode(256, Groups[I].Red);
ReadHuffCode(256, Groups[I].Blue);
ReadHuffCode(256, Groups[I].Alpha);
ReadHuffCode(40, Groups[I].Dist);

In der Fixture, die beim Hochziehen verwendet wurde, traten die drei Abweichungen bei Bit 47, Bit 81 und Bit 89 in dieser Reihenfolge auf. Diese Zahlen sind der Punkt dieses Abschnitts. Keine der drei Abweichungen meldete sich als Off-by-one; jede zeigte sich als Bild, das bis zum Ende dekodiert wurde und wie Statik aussah. Nur die exakte Bitposition, an der der Stream nicht mehr mit einer Referenz übereinstimmte, unterschied sie

Was bringt das Diffing nach Bitposition?

Bitpositions-Diffing verwandelt eine nutzlose Frage in eine Frage mit einer Zeile Antwort: nicht warum ist dieses Bild falsch, sondern warum wich der Stream bei Bit 81 ab. Der Aufbau ist günstig. Pillow schreibt neben jeder .webp-Fixture einen .rgba-Dump seiner eigenen Dekodierung desselben Bildes; eine Pascal-Sonde und ein kleines Python-Referenzmodell protokollieren neben jedem Lesezugriff einen laufenden Bitzähler; die erste Position, an der die beiden Logs auseinanderlaufen, ist der Ort des Fehlers. Beginnen Sie mit einer Fixture, die möglichst wenig ausübt: ein flaches 32×32-Bild, das nur den Simple-Code-Pfad nutzt. Bringen Sie das in Ordnung und fügen Sie dann Gradienten, ungewöhnliche Dimensionen und Alpha einzeln hinzu. Stattdessen die Bitordnung zu erraten, ist eine Methode, einen Tag zu verlieren

Die ehrliche Einschränkung: Auch die Referenz war falsch. Das Python-Modell vergaß, cache_bits zu lesen, und seine Transformationsschleife lief nicht bis zum Ende. Manche Abweichungspunkte waren daher ein Synchronisationsverlust des Referenzdecoders und nicht des Pascal-Decoders. Eine fehlerhafte Referenzimplementierung macht die zu testende Implementierung nicht automatisch richtig, und keine Seite erhält einen Vertrauensvorschuss: Jede Abweichung muss gegen den RFC-Text entschieden werden. Holen Sie auch diesen Text aus der Quelle. Suchzusammenfassungen verunstalten numerische Tabellen regelmäßig, und die Abstandsmap mit 120 Einträgen, die 14 Predictor-Modi und der Color-Cache-Multiplikator $1e35a7bd müssen exakt übertragen werden

Wo die Pascal-Ganzzahldivision von C abweicht

Die VP8L-Color-Transform ist eine Festkommadarstellung mit 3,5 und vorzeichenbehafteten Deltas, und dort hören Pascal und C auf, dasselbe zu tun. C verschiebt negative Ganzzahlen arithmetisch, was abrundet; Pascal div schneidet gegen null ab. Für jedes negative Produkt unterscheiden sich beide um eins, sodass die inverse Farbtransformation über das gesamte Bild pro Pixel um einen Kanalschritt driftet. HotPDF führt den Floor daher in FloorDiv32 ausdrücklich aus, statt sich auf div zu verlassen

// C verschiebt arithmetisch und rundet bei negativen Werten ab; Pascal div
// schneidet gegen null ab, daher braucht der negative Fall eine Korrektur
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// Festkomma-Delta 3,5 zwischen Transformations- und Farbkanalbyte,
// beide zuerst mit Vorzeichen erweitert
function ColorDelta(T, C: Integer): Integer;
var
  T8, C8: Integer;
begin
  T8 := T;
  if T8 >= 128 then
    Dec(T8, 256);
  C8 := C;
  if C8 >= 128 then
    Dec(C8, 256);
  Result := FloorDiv32(T8 * C8);
end;

Diese Fehlerklasse sollte man benennen, weil sie bei jeder Fixture unsichtbar bleibt, deren Produkte zufällig nichtnegativ sind und bei denen div und Floor übereinstimmen. Sie ist auch der Grund, warum die HotPDF-WebP-Tests eine pixelgenaue Gleichheit zu Pillow-Decodes derselben Dateien und keine Toleranz prüfen: Gradienten, eine ungewöhnliche Größe 100×37, ein 40×40-Bild mit echtem Alphakanal und ein flaches 32×32-Bild – jedes Pixel wird Bit für Bit verglichen. Ein Drift um einen Schritt besteht eine Wahrnehmungsprüfung und fällt bei einer bitweisen Prüfung durch

Was die WebP-Unterstützung bewusst ablehnt

HotPDF dekodiert nur den ersten VP8L-Chunk einer WebP-Datei und nichts weiter. Verlustbehaftete VP8-Frames, Animationen und jeder Container, dessen passender Chunk nicht VP8L ist, liefern aus HPDFDecodeWebPLossless False; AddImage macht daraus eine Exception mit dem Dateinamen: Failed to decode WebP image (lossless VP8L only). Das ist eine bewusste Grenze und kein Versäumnis: Eine Datei im falschen Format soll dort scheitern, wo der Aufrufer sie vorher konvertieren kann, statt ein graues Rechteck zu erzeugen. Das Versionsfeld muss 0 sein, der Transformationsstack ist auf vier Einträge begrenzt, und jede Grenzverletzung löst EWebPDecode aus, die der öffentliche Einstiegspunkt in ein einfaches False umwandelt. Das Dekodieren beim Import ist außerdem die entgegengesetzte Richtung zum Herausziehen von Bildern aus einem geöffneten Dokument; dafür gibt es den Pfad zum Extrahieren von Bildern aus einem geladenen PDF und seinen Decode-Filtern. Jeder Bilddecoder ist zudem ein Parser für Dateien, die Sie nicht erzeugt haben: Kommen WebP-Assets von Kunden oder aus dem öffentlichen Internet, sind die Grenzprüfungen hier die Untergrenze und nicht das Maximum. Die stärkere Antwort ist Bild-Codecs in einem isolierten Worker-Prozess auszuführen, damit ein fehlerhafter Frame den Host nicht mitreißen kann

Das praktische Ergebnis ist, dass eine Delphi- oder C++Builder-Anwendung WebP-Assets jetzt genauso in ein PDF legen kann wie PNG: ein Aufruf von AddImageFromFile, ein Aufruf von ShowImage und nichts Zusätzliches im Installer. Wenn Sie die übrige Bild- und Dokumentpipeline benötigen, die darum liegt, deckt die HotPDF Delphi PDF component Schreiben, Laden und Rendern aus demselben Unit-Set ab