Technical Article

WebP to PDF in Delphi: Inside the HotPDF VP8L Decoder

HotPDF 2.747.0 decodes WebP images with a VP8L (WebP lossless) decoder written from scratch in Object Pascal, so THotPDF.AddImageFromFile accepts a .webp path directly, with no libwebp DLL to ship and no helper process to launch. The decoder implements RFC 9649 section 3 in full: the RIFF container walk, canonical prefix codes, LZ77 backward references, the colour cache, and all four inverse transforms. Lossy VP8 frames are refused loudly rather than half-decoded

The trigger was mundane. A design tool exports every asset as WebP because that is the modern default, the assets land in an invoice or catalogue generator that has happily eaten PNG and JPEG for a decade, and suddenly half the inputs are rejected. The obvious fix is to bind libwebp and move on. The obvious fix is also the one that turns a self-contained VCL component into something with a deployment story

Why implement VP8L instead of binding libwebp?

HotPDF implements the codec in Pascal because a Delphi component that customers compile into their own executable cannot quietly acquire a runtime DLL. A native dependency means a 32-bit and a 64-bit binary to track, a version to pin, a code-signing chain to explain to whoever runs the deployment, and one more file that antivirus software on a locked-down terminal may decide it dislikes. For a component whose main selling point is that it drops into a project and works, that is a real cost, not a theoretical one. The other half of the argument is that VP8L is small: a prefix-code plus LZ77 format with four inverse transforms and a 120-entry neighbourhood distance map, and the whole decoder in HPDFWebP.pas is under 900 lines of Pascal. Inside THotPDF.AddImage the WebP branch sits at the same extension dispatch that already routes .jp2, .j2k, .jpt and .jpc through the JPEG 2000 path, so the plumbing was already there, the same place described in the walkthrough of adding JPEG 2000 images to PDFs in Delphi. Callers who want raw pixels instead of a PDF image can go straight to HPDFDecodeWebPLossless, which fills a TWebPCardinalArray of $AARRGGBB values in scan-line order

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp is dispatched to the built-in VP8L decoder, no DLL involved
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Why does a VP8L bitstream read in two directions at once?

Because the container bit order and the prefix code bit order are specified independently, and VP8L picks opposite conventions for them. RFC 9649 section 3.2 states plainly that the bitstream is read least significant bit first: the reader starts at bit 0 of a byte and walks up. The canonical prefix codes carried inside that stream arrive most significant bit first, root of the tree first, so the decode walk shifts its accumulator left and ORs each new bit in at the bottom. Reader and code walk therefore run in opposite directions inside the same loop, which looks like a bug every time you reread it

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;

// the canonical walk runs the other way: the first bit off the stream
// is the most significant bit of the code
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;

Three RFC details that silently desynchronise the stream

Three semantics in RFC 9649 are stated exactly once, are easy to read past, and each one costs or saves a single bit, which is enough to turn every later table into noise. All three were found in the HotPDF VP8L decoder, and all three produce the same symptom: a plausible-looking image that is wrong everywhere

  • An entropy-coded image in a non-primary role writes no meta-prefix bit at all. The ABNF for entropy-coded-image simply does not contain the item, so reading one desynchronises the stream by a bit. HotPDF passes AllowMeta = False for the entropy image itself, for predictor and colour transform data, and for the colour-indexing palette
  • A single-leaf prefix code consumes zero bits. RFC 9649 section 3.7.2.1 says so directly, and the canonical walk would happily read a bit and then fail to place it, so BuildHuff detects a total symbol count of 1 and marks the tree Single, decoding that one symbol without touching the reader
  • A cache_bits value of 0 means the colour cache size is 0, not 1 shl 0. The convenient shift gives 1, which makes the green alphabet 256 + 24 + CacheSize come out as 281 instead of 280, and every prefix code table read after it is misaligned
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 really means no 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: only the spatially coded (ARGB) image carries the
// meta prefix bit; entropy-coded roles never write it
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

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

On the fixture used during bring-up the three surfaced at bit 47, bit 81 and bit 89, in that order. Those numbers are the point of this section. Not one of the three announced itself as an off-by-one; each one presented as an image that decoded to completion and looked like static, and the only thing that separated them was the exact bit position at which the stream stopped agreeing with a reference

What does bit-position diffing buy you?

Bit-position diffing converts a useless question into a one-line question: not why is this picture wrong but why did the stream diverge at bit 81. The setup is cheap. Pillow writes each .webp fixture plus a .rgba dump of its own decode of the same image; a Pascal probe and a small Python reference model both log a running bit counter beside every read; the first position where the two logs disagree is where the bug lives. Start with a fixture that exercises as little as possible: a flat 32x32 image that only takes the simple-code path. Get that green, then add gradients, odd dimensions and alpha one fixture at a time. Guessing bit order instead is a way to spend a day

The honest caveat is that the reference was wrong too. The Python model forgot to read cache_bits and its transform loop did not run to completion, so some divergence points were the reference decoder losing sync, not the Pascal one. A reference implementation being wrong does not make the implementation under test right, and neither side gets the benefit of the doubt: each divergence has to be adjudicated against the RFC text. Pull that text from the source, too. Search summaries routinely mangle numeric tables, and the 120-entry distance map, the 14 predictor modes and the colour cache multiplier $1e35a7bd all have to be transcribed exactly

Where Pascal integer division diverges from C

The VP8L colour transform is 3.5 fixed point with signed deltas, and that is where Pascal and C stop agreeing. C shifts negative integers arithmetically, which floors; Pascal div truncates towards zero. For any negative product the two differ by one, so the inverse colour transform drifts by one channel step per pixel across the whole image. HotPDF therefore does the floor explicitly in FloorDiv32 rather than relying on div

// C shifts arithmetically and floors on negatives; Pascal div truncates
// towards zero, so the negative case needs an explicit correction
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 between a transform element byte and a
// colour channel byte, both sign-extended first
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;

This class of defect is worth naming because it is invisible to any test whose fixtures happen to produce non-negative products, where div and floor agree. It is also the reason the HotPDF WebP tests assert pixel-exact equality against Pillow decodes of the same files rather than a tolerance: gradients, an odd 100x37 size, a 40x40 image with a real alpha channel, and a flat 32x32 image, every pixel compared bit for bit. A one-step drift passes a perceptual check and fails a bitwise one

What the WebP support deliberately refuses

HotPDF decodes the first VP8L chunk of a WebP file and nothing else. Lossy VP8 frames, animations, and any container whose matching chunk is not VP8L return False from HPDFDecodeWebPLossless, and AddImage turns that into an exception naming the file: Failed to decode WebP image (lossless VP8L only). That is a deliberate boundary, not an oversight: a wrong-format file should fail where the caller can pre-convert it, rather than produce a grey rectangle. The version field must be 0, the transform stack is capped at four entries, and every bounds violation raises EWebPDecode, which the public entry point converts into a plain False. Decoding on import is also the opposite direction from pulling pictures back out of a document you opened, which runs through the loaded-image path described in extracting images from a loaded PDF and their decode filters. And any image decoder is a parser fed by files you did not create: if the WebP assets arrive from customers or the public internet, the bounds checks here are the floor rather than the ceiling, and the stronger answer is running image codecs in an isolated worker process so a malformed frame cannot take the host down with it

The practical result is that a Delphi or C++Builder application can now put WebP assets into a PDF the same way it puts in PNG: one call to AddImageFromFile, one call to ShowImage, nothing extra in the installer. If you want the rest of the image and document pipeline that sits around it, the HotPDF Delphi PDF component covers the writing, loading and rendering sides from the same unit set