Műszaki cikk

WebP PDF-be Delphiben: a HotPDF VP8L dekódere

A HotPDF 2.747.0 Object Pascalban a nulláról írt VP8L (WebP lossless) dekóderrel dekódolja a WebP-képeket, ezért a THotPDF.AddImageFromFile közvetlenül elfogad egy .webp útvonalat, telepítendő libwebp DLL és indítandó segédfolyamat nélkül. A dekóder teljes egészében megvalósítja az RFC 9649 3. szakaszát: a RIFF-konténer bejárását, a kanonikus prefixkódokat, az LZ77 backward reference-eket, a color cache-t és mind a négy inverz transzformációt. A veszteséges VP8 frame-eket határozottan elutasítja, nem próbálja félig dekódolni

A kiváltó ok hétköznapi volt. Egy designeszköz minden assetet WebP-ként exportál, mert ez a modern alapértelmezés, az assetek pedig egy olyan számla- vagy katalógusgenerátorba kerülnek, amely évtizede gond nélkül fogyaszt PNG-t és JPEG-et, és hirtelen a bemenetek fele elutasításra kerül. A kézenfekvő javítás a libwebp bekötése és továbblépés. Ez a kézenfekvő javítás ugyanakkor egy önálló VCL-komponenst telepítési történettel terhel

Miért VP8L-t valósított meg a HotPDF a libwebp bekötése helyett?

A HotPDF azért Object Pascalban valósította meg a codecet, mert egy olyan Delphi-komponens, amelyet az ügyfelek a saját futtathatójukba fordítanak, nem szerezhet csendben runtime DLL-t. Egy natív függőség egy 32 és egy 64 bites bináris követését, rögzítendő verziót, elmagyarázandó code-signing láncot és még egy fájlt jelent, amelyről egy lezárt terminál vírusirtója eldöntheti, hogy nem kedveli. Egy olyan komponensnél, amelynek fő ígérete, hogy projektbe ejted és működik, ez valós költség, nem elméleti. Az érv másik fele, hogy a VP8L kicsi: prefixkód- és LZ77-formátum négy inverz transzformációval és 120 elemű szomszédsági távolságtérképpel, a teljes dekóder pedig a HPDFWebP.pas-ban 900 Pascal-sor alatti. A THotPDF.AddImage-ben a WebP-ág ugyanazon extension-dispatchban ül, amely már a .jp2, .j2k, .jpt és .jpc kiterjesztést a JPEG 2000 útvonalra küldi, ezért a plumbing már adott volt, ugyanott, ahol a JPEG 2000 képek PDF-be adásáról szóló Delphi-útmutató is tárgyalja. Aki PDF-kép helyett nyers pixeleket akar, közvetlenül a HPDFDecodeWebPLossless-t hívhatja, amely scan-line sorrendben egy TWebPCardinalArray $AARRGGBB értékeit tölti ki

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // A .webp a beépített VP8L dekóderre kerül, nincs DLL
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Miért olvas a VP8L bitstream egyszerre két irányba?

Azért, mert a konténer bitrendje és a prefixkód bitrendje egymástól függetlenül van specifikálva, a VP8L pedig ellentétes konvenciót választ hozzájuk. Az RFC 9649 3.2 szakasza világosan kimondja, hogy a bitstreamet least-significant-bit first kell olvasni: az olvasó egy byte 0. bitjéről indul, és felfelé halad. A streamben hordozott kanonikus prefixkódok most a legjelentősebb bittel érkeznek először, a fa gyökerétől indulva, ezért a dekódoló ciklus balra tolja az akkumulátort, és az új bitet alul OR-olja be. Az olvasás és a kódbejárás tehát ugyanabban a ciklusban ellentétes irányban halad, ami minden újraolvasáskor hibának tűnik

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;

// a kanonikus bejárás ellenkező irányú: a streamből elsőként érkező bit
// a kód legjelentősebb bitje
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;

Három RFC-részlet, amely csendben deszinkronizálja a streamet

Az RFC 9649 három szemantikát pontosan egyszer mond ki, könnyű átsiklani felettük, és mindegyik egyetlen bitet takarít meg vagy fogyaszt el, ami elég ahhoz, hogy minden későbbi tábla zajjá váljon. Mindhárom előkerült a HotPDF VP8L-dekóderében, és mindhárom ugyanazt a tünetet adja: hihetőnek látszó, de mindenütt hibás képet

  • Egy nem elsődleges szerepben lévő entropy-coded image egyáltalán nem ír meta-prefix bitet. Az entropy-coded-image ABNF-je egyszerűen nem tartalmazza ezt az elemet, ezért egy bit olvasása eltolná a streamet. A HotPDF AllowMeta = False értéket ad magához az entropy image-hez, a predictor- és color-transform adatokhoz, valamint a color-indexing palettához
  • Egyetlen levélből álló prefixkód nulla bitet fogyaszt. Az RFC 9649 3.7.2.1 ezt közvetlenül kimondja, a kanonikus bejárás viszont készségesen olvasna egy bitet, majd nem tudná elhelyezni, ezért a BuildHuff felismeri az 1-es teljes szimbólumszámot, Single-ként jelöli a fát, és az egyetlen szimbólumot az olvasó érintése nélkül dekódolja
  • A 0 értékű cache_bits a color cache mérete 0, nem 1 shl 0. A kényelmes shift 1-et adna, ezért a zöld ábécé 256 + 24 + CacheSize értéke 280 helyett 281 lenne, és minden utána olvasott prefixkódtábla elcsúszna
CacheBits := 0;
CacheSize := 0;                        // a cache_bits = 0 valóban semmit jelent
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: csak a térben kódolt (ARGB) image hordozza a
// meta prefix bitet; az entropy-coded szerepek soha nem írják ki
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

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

A bring-up során használt fixtúrán a három hiba a 47., 81. és 89. bitnél jelent meg, ebben a sorrendben. Ezek a számok ennek a résznek a lényege. Egyik sem off-by-one hibaként jelentkezett; mindegyik olyan képként, amely a dekódolást befejezte, de statikus zajnak látszott, és csak a pontos bitpozíció választotta el őket a referenciától, ahol a stream többé nem egyezett

Mit ad a bitpozíciós diffelés?

A bitpozíciós diffelés egy haszontalan kérdést egysoros kérdéssé alakít: nem azt kérdezed, miért hibás ez a kép, hanem azt, miért tért el a stream a 81. bitnél. A beállítás olcsó. A Pillow minden .webp fixtúrát és ugyanannak a képnek a saját dekódolásából származó .rgba dumpot kiírja; egy Pascal-probe és egy kis Python-referenciamodell minden olvasás mellé futó bitszámlálót logol; az első pont, ahol a két log eltér, jelzi a hibát. Olyan fixtúrával kezdj, amely a lehető legkevesebbet gyakorolja: egy lapos 32×32-es képpel, amely csak az egyszerű kódútvonalat használja. Ha ez zöld, adj hozzá gradienst, furcsa méreteket és alphát egyesével. A bitrend találgatása ehelyett csak egy nap elpazarlásának módja

Az őszinte kiegészítés, hogy a referencia is hibás volt. A Python-modell elfelejtette olvasni a cache_bits-et, a transform ciklusa pedig nem futott végig, ezért néhány eltérési pont a referencia dekóder szinkronvesztése volt, nem a Pascal-kódé. Egy hibás referencia-implementáció nem teszi helyessé a tesztelt implementációt, és egyik fél sem kapja meg a jóhiszeműség előnyét: minden eltérést az RFC szövege alapján kell eldönteni. Ezt a szöveget is a forrásból vedd. A keresési összefoglalók gyakran elrontják a numerikus táblákat, a 120 elemű távolságtérképet, a 14 predictor-módot és a $1e35a7bd color-cache szorzót pedig pontosan kell átírni

Hol tér el a Pascal egészosztása a C-től?

A VP8L color transform 3,5-ös fixpontos, előjeles deltákkal, és itt szűnik meg a Pascal és a C egyetértése. A C az előjeles egész számokat aritmetikusan shifteli, ami lefelé kerekít, a Pascal div viszont nulla felé csonkol. Minden negatív szorzatnál eggyel eltérnek, ezért az inverz color transform képenként és csatornánként egy lépéssel elcsúszik. A HotPDF ezért a FloorDiv32-ben expliciten lefelé kerekít, nem a div-re hagyatkozik

// A C aritmetikusan shiftel és negatív értéknél lefelé kerekít; a Pascal div
// nulla felé csonkol, ezért a negatív esethez explicit korrekció kell
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// 3,5-ös fixpontos delta egy transform elem byte-ja és egy
// color channel byte-ja között, előbb mindkettőt előjelesen kiterjesztve
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;

Ezt a hibacsoportot érdemes megnevezni, mert láthatatlan minden olyan teszten, amelynek fixtúrái véletlenül csak nem negatív szorzatokat állítanak elő, ahol a div és a floor egybeesik. Ezért ellenőrzik a HotPDF WebP-tesztek a pixelek bitpontos azonosságát a Pillow ugyanazon fájlokból készített dekódolásával, nem pedig toleranciát használnak: gradienteket, furcsa 100×37-es méretet, valódi alphacsatornás 40×40-es képet és lapos 32×32-es képet, minden pixelt bitről bitre. Az egylépéses elcsúszás átmegy a perceptuális ellenőrzésen, a bitenkéntin elbukik

Mit utasít el szándékosan a WebP-támogatás?

A HotPDF egy WebP-fájl első VP8L chunkját dekódolja, mást nem. A veszteséges VP8 frame-ek, animációk és minden olyan konténer, amelynek illeszkedő chunkja nem VP8L, False értékkel tér vissza a HPDFDecodeWebPLossless-ből, az AddImage pedig ezt a fájlt megnevező kivétellé alakítja: Failed to decode WebP image (lossless VP8L only). Ez tudatos határ, nem hiányosság: a rossz formátumú fájl ott bukjon el, ahol a hívó előre átkonvertálhatja, ahelyett hogy szürke téglalapot kapna. A verziómezőnek 0-nak kell lennie, a transform stack legfeljebb négy bejegyzéses, minden bounds-sértés EWebPDecode-ot emel, a publikus belépési pont pedig ezt sima False értékké alakítja. Az import közbeni dekódolás a dokumentumból visszanyert képek ellentétes iránya, amelyet a betöltött PDF képeinek és dekódolási filtereinek kinyeréséről szóló útvonal kezel. Bármely képdekóder olyan parser, amelyet nem te általad készített fájlok táplálnak: ha a WebP-assetek ügyfelektől vagy a nyilvános internetről jönnek, az itteni bounds-ellenőrzések az alsó szintet jelentik, az erősebb válasz pedig a kép-codecek izolált worker-folyamatban futtatása, hogy egy hibás frame ne rántsa magával a hostot

A gyakorlati eredmény, hogy egy Delphi vagy C++Builder alkalmazás ugyanúgy tehet WebP-assetet PDF-be, mint PNG-t: egy AddImageFromFile hívás, egy ShowImage hívás, semmi extra az installerben. Ha a körülötte lévő teljes kép- és dokumentumpipeline is érdekel, a HotPDF Delphi PDF component ugyanabból a unitkészletből fedezi az írási, betöltési és renderelési oldalt