Teknisk artikel

WebP til PDF i Delphi: inde i HotPDFs VP8L-decoder

HotPDF 2.747.0 afkoder WebP-billeder med en VP8L-decoder (WebP lossless), der er skrevet fra bunden i Object Pascal, så THotPDF.AddImageFromFile accepterer en .webp-sti direkte uden en libwebp-DLL, der skal distribueres, og uden en hjælpeproces, der skal startes. Decoderen implementerer RFC 9649 afsnit 3 fuldt ud: RIFF-container-gennemgangen, kanoniske prefix-koder, LZ77-backward-references, color cache og alle fire inverse transforms. Lossy VP8-frames afvises tydeligt i stedet for at blive halvt afkodet

Udløseren var banal. Et designværktøj eksporterer hvert asset som WebP, fordi det er den moderne standard, assets lander i en faktura- eller kataloggenerator, der ubesværet har spist PNG og JPEG i et årti, og pludselig bliver halvdelen af inputtene afvist. Den oplagte løsning er at binde libwebp og komme videre. Den oplagte løsning er også den, der forvandler en selvstændig VCL-komponent til noget med en deployment-historie

Hvorfor implementere VP8L i stedet for at binde libwebp?

HotPDF implementerer codecet i Pascal, fordi en Delphi-komponent, som kunder kompilerer ind i deres egen eksekverbare fil, ikke lydløst kan anskaffe sig en runtime-DLL. En native afhængighed betyder en 32-bit- og en 64-bit-binær at holde styr på, en version at pinne, en code-signing-kæde at forklare til den, der kører deploymenten, og endnu en fil, som antivirussoftware på en låst terminal kan beslutte sig for ikke at kunne lide. For en komponent, hvis vigtigste salgsargument er, at den droppes ind i et projekt og virker, er det en reel pris og ikke en teoretisk. Den anden halvdel af argumentet er, at VP8L er lille: et prefix-codec plus LZ77-format med fire inverse transforms og et neighborhood distance map med 120 poster, og hele decoderen i HPDFWebP.pas er under 900 linjer Pascal. Inde i THotPDF.AddImage ligger WebP-grenen i den samme extension-dispatch, der allerede sender .jp2, .j2k, .jpt og .jpc gennem JPEG 2000-stien, så plumbing var allerede på plads, på samme sted som beskrevet i gennemgangen af tilføjelse af JPEG 2000-billeder til PDF-filer i Delphi. Kaldere, der ønsker rå pixels i stedet for et PDF-billede, kan gå direkte til HPDFDecodeWebPLossless, som udfylder en TWebPCardinalArray med $AARRGGBB-værdier i scanline-rækkefølge

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp sendes til den indbyggede VP8L-decoder, ingen DLL involveret
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Hvorfor læses en VP8L-bitstream i to retninger på én gang?

Fordi containerens bitrækkefølge og prefix-kodens bitrækkefølge er specificeret uafhængigt, og VP8L vælger modsatte konventioner for dem. RFC 9649 afsnit 3.2 siger det klart, at bitstreamen læses least significant bit first: Læseren starter ved bit 0 i en byte og går opad. De kanoniske prefix-koder, der ligger i streamen, kommer most significant bit first, træets rod først, så decode-gangen skifter sin akkumulator til venstre og ORer hver ny bit ind i bunden. Reader- og code-gangen løber derfor i modsatte retninger inde i samme løkke, hvilket ligner en fejl, hver gang man læser det igen

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

// den kanoniske gennemgang går den anden vej: den første bit fra streamen
// er den mest betydende bit i koden
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;

Tre RFC-detaljer, der lydløst desynkroniserer streamen

Tre semantikker i RFC 9649 bliver kun sagt én gang, er nemme at læse forbi, og hver af dem koster eller sparer én bit, hvilket er nok til at forvandle alle senere tabeller til støj. Alle tre blev fundet i HotPDFs VP8L-decoder, og alle tre giver det samme symptom: et billede, der ser plausibelt ud, afkodes helt og er forkert overalt

  • Et entropy-kodet billede i en ikke-primær rolle skriver slet ingen meta-prefix-bit. ABNFen for entropy-coded-image indeholder ganske enkelt ikke elementet, så hvis man læser én, desynkroniseres streamen med én bit. HotPDF sender AllowMeta = False for selve entropy-billedet, for predictor- og color-transform-data og for color-indexing-paletten
  • En prefix-kode med ét blad bruger nul bits. RFC 9649 afsnit 3.7.2.1 siger det direkte, og den kanoniske gennemgang ville gladeligt læse en bit og derefter ikke placere den, så BuildHuff detekterer et samlet symbolantal på 1 og markerer træet Single, så det ene symbol afkodes uden at røre readeren
  • En cache_bits-værdi på 0 betyder, at color-cache-størrelsen er 0 og ikke 1 shl 0. Det bekvemme shift giver 1, hvilket får det grønne alfabet 256 + 24 + CacheSize til at blive 281 i stedet for 280, og hver prefix-kodetabel, der læses efter det, bliver skæv
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 betyder virkelig ingen cache
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: kun det spatialt kodede (ARGB)-billede har meta-
// prefix-biten; entropy-kodede roller skriver den aldrig
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

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

På det fixture, der blev brugt under bring-up, dukkede de tre op ved bit 47, bit 81 og bit 89 i den rækkefølge. Det er pointen med denne sektion. Ikke én af de tre annoncerede sig som en off-by-one; hver præsenterede sig som et billede, der blev afkodet til ende og lignede statisk støj, og det eneste, der adskilte dem, var den præcise bitposition, hvor streamen holdt op med at stemme overens med en reference

Hvad får man ud af bit-position-diffing?

Bit-position-diffing omdanner et ubrugeligt spørgsmål til et spørgsmål på én linje: ikke hvorfor er dette billede forkert, men hvorfor divergerede streamen ved bit 81. Opsætningen er billig. Pillow skriver hvert .webp-fixture plus et .rgba-dump af sin egen afkodning af det samme billede; en Pascal-probe og en lille Python-reference-model logger begge en løbende bitcounter ved siden af hver læsning; den første position, hvor de to logs er uenige, er dér, fejlen bor. Start med et fixture, der aktiverer så lidt som muligt: et fladt 32x32-billede, der kun tager simple-code-stien. Få det til at blive grønt, og tilføj derefter gradienter, ulige dimensioner og alpha ét fixture ad gangen. At gætte bitrækkefølgen i stedet er en måde at bruge en dag på

Det ærlige forbehold er, at referencen også var forkert. Python-modellen glemte at læse cache_bits, og dens transform-løkke kørte ikke til ende, så nogle divergenspunkter var referencedeckoderen, der mistede synkronisering, og ikke Pascal-versionen. At en referenceimplementering er forkert, gør ikke implementationen under test rigtig, og ingen af siderne får fordel af tvivlen: Hver divergens skal afgøres mod RFC-teksten. Hent også teksten fra kilden. Søgeresuméer forvrænger rutinemæssigt numeriske tabeller, og distance-mappet med 120 poster, de 14 predictor-modes og color-cache-multiplikatoren $1e35a7bd skal alle transskriberes præcist

Hvor afviger Pascals heltalsdivision fra C?

VP8L-color-transformen er 3.5 fixed point med signed deltas, og det er dér, Pascal og C holder op med at være enige. C shifter negative heltal aritmetisk, hvilket giver floor; Pascal div trunkerer mod nul. For ethvert negativt produkt adskiller de to sig med én, så den inverse color transform driver med ét kanaltrin pr. pixel gennem hele billedet. HotPDF udfører derfor floor eksplicit i FloorDiv32 i stedet for at stole på div

// C shifter aritmetisk og floorer ved negative værdier; Pascal div trunkerer
// mod nul, så det negative tilfælde kræver en eksplicit korrektion
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// 3.5 fixed-point-delta mellem en transformelements byte og en
// farvekanal-byte, hvor begge først sign-extends
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;

Denne type defekt er værd at navngive, fordi den er usynlig for enhver test, hvis fixtures tilfældigvis giver ikke-negative produkter, hvor div og floor er enige. Det er også grunden til, at HotPDFs WebP-tests hævder pixelidentisk lighed med Pillow-dekodninger af de samme filer i stedet for en tolerance: Gradienter, en ulige størrelse på 100x37, et 40x40-billede med en ægte alpha-kanal og et fladt 32x32-billede, hver pixel sammenlignet bit for bit. En drift på ét trin består en perceptuel kontrol og fejler en bitvis kontrol

Hvad afviser WebP-understøttelsen bevidst?

HotPDF afkoder den første VP8L-chunk i en WebP-fil og intet andet. Lossy VP8-frames, animationer og enhver container, hvis matchende chunk ikke er VP8L, returnerer False fra HPDFDecodeWebPLossless, og AddImage gør det til en exception, der nævner filen: Failed to decode WebP image (lossless VP8L only). Det er en bevidst grænse og ikke en forglemmelse: En fil i forkert format skal fejle dér, hvor kalderen kan konvertere den på forhånd, i stedet for at producere et gråt rektangel. Versionsfeltet skal være 0, transform-stacken er begrænset til fire entries, og enhver overskridelse af bounds rejser EWebPDecode, som det offentlige entry point konverterer til en almindelig False. Afkodning ved import er også den modsatte retning af at hente billeder ud af et dokument, du har åbnet, hvilket går gennem loaded-image-stien beskrevet i ekstraktion af billeder fra en indlæst PDF og deres decode-filtre. Og enhver billeddecoder er en parser, der får filer, du ikke har skabt: Hvis WebP-assets kommer fra kunder eller det offentlige internet, er bounds-kontrollerne her gulvet og ikke loftet, og det stærkere svar er at køre image codecs i en isoleret worker-proces, så en malformed frame ikke kan tage værten med ned

Det praktiske resultat er, at en Delphi- eller C++Builder-applikation nu kan lægge WebP-assets i en PDF på samme måde som PNG: ét kald til AddImageFromFile, ét kald til ShowImage, intet ekstra i installationsprogrammet. Hvis du vil have resten af billed- og dokumentpipelinen omkring det, dækker HotPDF Delphi PDF-komponenten skrive-, indlæsnings- og renderings-siderne fra det samme unitsæt