Articol tehnic

WebP în PDF în Delphi: decoder-ul VP8L din HotPDF

HotPDF 2.747.0 decodează imagini WebP cu un decoder VP8L (WebP lossless) scris de la zero în Object Pascal, astfel încât THotPDF.AddImageFromFile acceptă direct o cale .webp, fără DLL libwebp de livrat și fără helper process de pornit. Decoder-ul implementează integral secțiunea 3 din RFC 9649: parcurgerea container-ului RIFF, prefix codes canonice, backward references LZ77, color cache și toate cele patru inverse transforms. Frame-urile VP8 lossy sunt refuzate explicit, nu decodate pe jumătate

Declanșatorul a fost banal. Un design tool exportă fiecare asset ca WebP, fiindcă acesta este default-ul modern, asset-urile ajung într-un generator de facturi sau cataloage care a consumat fără probleme PNG și JPEG timp de un deceniu, iar dintr-odată jumătate dintre input-uri sunt respinse. Fix-ul evident este să legi libwebp și să mergi mai departe. Fix-ul evident este și cel care transformă un VCL component self-contained într-un lucru cu deployment story

De ce să implementezi VP8L în loc să legi libwebp?

HotPDF implementează codec-ul în Pascal deoarece un component Delphi pe care clienții îl compilează în propriul executabil nu poate dobândi în tăcere un runtime DLL. O dependență nativă înseamnă un binary pe 32 de biți și unul pe 64 de urmărit, o versiune de fixat, un lanț de code signing de explicat celui care face deployment-ul și încă un fișier pe care antivirusul de pe un terminal blocat poate decide că nu îl place. Pentru un component al cărui principal avantaj este că îl pui într-un project și funcționează, acesta este un cost real, nu teorie. Cealaltă jumătate a argumentului este că VP8L este mic: un format cu prefix codes și LZ77, cu patru inverse transforms și un distance map de 120 de intrări pentru vecinătate, iar decoder-ul întreg din HPDFWebP.pas are sub 900 de linii Pascal. În THotPDF.AddImage, ramura WebP stă în același extension dispatch care direcționează deja .jp2, .j2k, .jpt și .jpc prin ruta JPEG 2000, așa că plumbing-ul exista deja, în același loc descris în ghidul despre adăugarea imaginilor JPEG 2000 în PDF-uri în Delphi. Caller-ii care vor pixeli raw, nu o imagine PDF, pot merge direct la HPDFDecodeWebPLossless, care umple un TWebPCardinalArray de valori $AARRGGBB în ordine scan-line

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp este trimis la decoder-ul VP8L inclus, fără DLL
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

De ce citește un bitstream VP8L în două direcții simultan?

Deoarece ordinea biților din container și ordinea biților din prefix code sunt specificate independent, iar VP8L alege convenții opuse pentru ele. RFC 9649 secțiunea 3.2 spune direct că bitstream-ul este citit least significant bit first: reader-ul începe la bitul 0 al unui byte și urcă. Prefix codes canonice din interiorul stream-ului sosesc most significant bit first, cu rădăcina arborelui prima, așa că decode walk-ul deplasează accumulator-ul la stânga și face OR cu fiecare bit nou la bază. Reader-ul și code walk-ul rulează deci în direcții opuse în aceeași buclă, ceea ce arată ca un bug de fiecare dată când recitești codul

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;

// parcurgerea canonică merge invers: primul bit scos din stream
// este cel mai semnificativ bit al codului
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;

Trei detalii RFC care desincronizează în tăcere stream-ul

Trei semantici din RFC 9649 sunt menționate exact o dată, se citesc ușor pe diagonală și fiecare adaugă sau elimină un singur bit, suficient pentru a transforma fiecare tabel ulterior în noise. Toate trei au fost găsite în decoder-ul HotPDF VP8L și toate produc același simptom: o imagine cu aspect plauzibil, dar greșită peste tot

  • O imagine entropy-coded într-un rol non-primary nu scrie deloc un meta-prefix bit. ABNF pentru entropy-coded-image nu conține elementul, așa că citirea unuia desincronizează stream-ul cu un bit. HotPDF transmite AllowMeta = False pentru imaginea entropy însăși, pentru datele de predictor și color transform și pentru paleta color-indexing
  • Un prefix code cu o singură frunză consumă zero biți. RFC 9649 secțiunea 3.7.2.1 spune asta direct, iar canonical walk-ul ar citi fără ezitare un bit și apoi nu ar reuși să îl plaseze, așa că BuildHuff detectează un total de simboluri 1 și marchează arborele Single, decodând acel singur simbol fără să atingă reader-ul
  • O valoare cache_bits de 0 înseamnă o dimensiune de color cache 0, nu 1 shl 0. Shift-ul comod dă 1, ceea ce face ca alfabetul green 256 + 24 + CacheSize să ajungă la 281 în loc de 280, iar fiecare citire ulterioară a prefix code table este decalată
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 înseamnă cu adevărat none
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: numai imaginea spatially coded (ARGB) poartă
// meta prefix bit; rolurile entropy-coded nu îl scriu niciodată
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

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

În fixture-ul folosit la bring-up, cele trei au ieșit la bitul 47, bitul 81 și bitul 89, în această ordine. Aceste numere sunt ideea secțiunii. Niciuna dintre cele trei nu s-a anunțat ca off-by-one; fiecare s-a prezentat ca o imagine care se decoda până la capăt și arăta ca static, iar singurul lucru care le separa era poziția exactă a bitului la care stream-ul a încetat să fie de acord cu o referință

Ce îți aduce bit-position diffing?

Bit-position diffing transformă o întrebare inutilă într-una de o linie: nu de ce este greșită imaginea, ci de ce a deviat stream-ul la bitul 81. Setup-ul este ieftin. Pillow scrie fiecare fixture .webp plus un dump .rgba al propriului decode pentru aceeași imagine; o sondă Pascal și un mic reference model Python loghează un bit counter curent lângă fiecare read; prima poziție la care log-urile nu mai coincid este locul bug-ului. Începe cu un fixture care exercită cât mai puțin: o imagine flat 32x32 care folosește doar simple-code path. Fă acela verde, apoi adaugă pe rând gradients, dimensiuni impare și alpha. Să ghicești ordinea biților este în schimb o metodă de a pierde o zi

Nota onestă este că și referința era greșită. Modelul Python a uitat să citească cache_bits, iar transform loop-ul lui nu rula până la capăt, așa că unele puncte de divergență erau reference decoder-ul care își pierdea sincronizarea, nu decoder-ul Pascal. Faptul că o implementare de referință este greșită nu face corectă implementarea testată, iar niciuna dintre părți nu primește prezumția de nevinovăție: fiecare divergență trebuie adjudecată după textul RFC. Ia acel text din sursă, de asemenea. Rezumatele din search strică frecvent tabelele numerice, iar distance map-ul de 120 de intrări, cele 14 moduri de predictor și multiplier-ul color cache $1e35a7bd trebuie toate transcrise exact

Unde diferă împărțirea întreagă Pascal de C

Color transform-ul VP8L este fixed point 3.5 cu deltas signed și aici Pascal și C încetează să mai fie de acord. C face shift aritmetic al întregilor negativi, ceea ce aplică floor; Pascal div trunchiază spre zero. Pentru orice product negativ, cele două diferă cu unu, așa că inverse color transform-ul deviază cu un channel step per pixel de-a lungul întregii imagini. HotPDF face deci floor explicit în FloorDiv32 în loc să se bazeze pe div

// C face shift aritmetic și aplică floor pe valori negative; Pascal div trunchiază
// spre zero, așa că pentru cazul negativ este necesară o corecție explicită
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// delta fixed-point 3.5 dintre un byte de transform și un byte de channel,
// ambele cu semn extins mai întâi
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;

Merită numită această clasă de defecte deoarece este invizibilă pentru orice test ale cărui fixture-uri produc doar produse non-negative, unde div și floor coincid. Este și motivul pentru care testele HotPDF WebP verifică egalitatea pixel cu pixel față de decode-uri Pillow ale acelorași fișiere, nu o toleranță: gradients, o dimensiune impară de 100x37, o imagine 40x40 cu un alpha channel real și o imagine flat 32x32, fiecare pixel comparat bit cu bit. Un drift de un pas trece un check perceptual și eșuează la unul bitwise

Ce refuză intenționat suportul WebP

HotPDF decodează primul chunk VP8L al unui fișier WebP și nimic altceva. Frame-urile lossy VP8, animațiile și orice container al cărui chunk matching nu este VP8L returnează False din HPDFDecodeWebPLossless, iar AddImage transformă asta într-o excepție care numește fișierul: Failed to decode WebP image (lossless VP8L only). Este o limită deliberată, nu o omisiune: un fișier în format greșit trebuie să eșueze acolo unde caller-ul îl poate preconverti, nu să producă un dreptunghi gri. Câmpul version trebuie să fie 0, transform stack-ul este limitat la patru entry-uri, iar orice bounds violation ridică EWebPDecode, pe care public entry point-ul îl transformă în simplu False. Decoding-ul la import este și direcția opusă față de extragerea imaginilor dintr-un document deschis, care trece prin ruta loaded-image descrisă în extragerea imaginilor dintr-un PDF loaded și filtrele lor de decode. Iar orice image decoder este un parser alimentat cu fișiere pe care nu le-ai creat: dacă asset-urile WebP vin de la clienți sau de pe internetul public, bounds checks de aici sunt podeaua, nu tavanul, iar răspunsul mai puternic este rularea codecurilor de imagine într-un worker process izolat, astfel încât un frame malformed să nu doboare și host-ul

Rezultatul practic este că o aplicație Delphi sau C++Builder poate pune acum asset-uri WebP într-un PDF exact cum pune PNG: un apel la AddImageFromFile, un apel la ShowImage, nimic în plus în installer. Dacă vrei restul pipeline-ului de imagini și documente din jur, componenta PDF HotPDF pentru Delphi acoperă scrierea, încărcarea și randarea din același set de unit-uri