Технічна стаття

WebP у PDF в Delphi: усередині HotPDF VP8L decoder

HotPDF 2.747.0 декодує WebP images за допомогою VP8L (WebP lossless) decoder, написаного з нуля на Object Pascal, тому THotPDF.AddImageFromFile напряму приймає path до .webp, без libwebp DLL для постачання і без helper process для запуску. Decoder повністю реалізує section 3 RFC 9649: RIFF container walk, canonical prefix codes, LZ77 backward references, color cache і всі чотири inverse transforms. Lossy VP8 frames відхиляються явно, а не напівдекодуються

Тригер був буденним. Design tool експортує кожен asset як WebP, бо це сучасний default, assets потрапляють в invoice або catalog generator, який десятиліття без проблем їв PNG та JPEG, — і раптом половина inputs відхиляється. Очевидне виправлення — прив’язати libwebp і рухатися далі. Очевидне виправлення також перетворює self-contained VCL component на щось із окремою deployment story

Навіщо реалізовувати VP8L, а не прив’язувати libwebp?

HotPDF реалізує codec у Pascal, бо Delphi component, який customers компілюють у власний executable, не може тихо отримати runtime DLL. Native dependency означає 32-bit і 64-bit binary для tracking, version для pinning, code-signing chain для пояснення тому, хто запускає deployment, і ще один file, який antivirus на locked-down terminal може не схвалити. Для component, чия головна перевага — drop-in у project і робота, це реальна cost, а не теоретична. Друга половина аргументу — VP8L малий: prefix-code плюс LZ77 format із чотирма inverse transforms і 120-entry neighborhood distance map, а весь decoder у HPDFWebP.pas має менше 900 lines Pascal. Усередині THotPDF.AddImage WebP branch сидить у тому самому extension dispatch, який уже спрямовує .jp2, .j2k, .jpt та .jpc через JPEG 2000 path, тож plumbing уже був на місці — там само, де описано walkthrough про додавання JPEG 2000 images до PDF у Delphi. Callers, яким потрібні raw pixels, а не PDF image, можуть напряму викликати HPDFDecodeWebPLossless, який заповнює TWebPCardinalArray значеннями $AARRGGBB у scan-line order

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp спрямовується до built-in VP8L decoder, без DLL
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Чому VP8L bitstream читається одразу у двох напрямках?

Тому що bit order container і bit order prefix code задані незалежно, а VP8L вибирає для них протилежні conventions. RFC 9649 section 3.2 прямо каже, що bitstream читається least significant bit first: reader починає з bit 0 byte і рухається вгору. Canonical prefix codes, що містяться в stream, приходять most significant bit first, від root дерева, тож decode walk зсуває accumulator left і OR-ить кожен новий bit у нижній позиції. Тому reader і code walk ідуть у протилежних напрямках усередині того самого loop, і щоразу під час перечитування це виглядає як bug

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: молодший bit першим
  Inc(BitPos);
  if BitPos = 8 then
  begin
    BitPos := 0;
    Inc(BytePos);
  end;
end;

// canonical walk іде в інший бік: перший bit зі stream
// є most significant bit 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;

Три RFC details, які тихо розсинхронізують stream

Три semantics у RFC 9649 згадані рівно один раз, їх легко прочитати повз увагу, і кожна коштує або заощаджує один bit — цього досить, щоб усі наступні tables перетворилися на noise. Усі три знайшли в HotPDF VP8L decoder, і всі три дають один симптом: plausibly-looking image, яка всюди неправильна

  • Entropy-coded image у non-primary role не записує meta-prefix bit взагалі. ABNF для entropy-coded-image просто не містить цього item, тому читання одного bit зсуває stream. HotPDF передає AllowMeta = False для самої entropy image, для predictor і color transform data та для color-indexing palette
  • Single-leaf prefix code споживає zero bits. RFC 9649 section 3.7.2.1 прямо це каже, а canonical walk охоче прочитає bit і потім не зможе його розмістити, тому BuildHuff знаходить total symbol count 1 і позначає tree як Single, декодуючи цей один symbol без звернення до reader
  • Значення cache_bits 0 означає color cache size 0, а не 1 shl 0. Зручний shift дає 1, через що green alphabet 256 + 24 + CacheSize стає 281 замість 280, і кожне наступне prefix code table read виявляється misaligned
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 справді означає 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: лише spatially coded (ARGB) image має
// meta prefix bit; entropy-coded roles його ніколи не записують
if AllowMeta then
  UseMeta := BR.ReadBit
else
  UseMeta := 0;

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

На fixture, який використовували під час bring-up, три помилки проявилися на bit 47, bit 81 і bit 89, саме в такому порядку. У цьому й сенс section. Жодна з трьох не оголосила себе off-by-one; кожна показувала image, яка завершила decode і виглядала як static, а відрізнити їх можна було лише за exact bit position, у якій stream переставав збігатися з reference

Що дає diffing за bit position?

Bit-position diffing перетворює марне питання на однорядкове: не чому ця picture неправильна, а чому stream розійшовся на bit 81. Setup дешевий. Pillow записує для кожного .webp fixture плюс .rgba dump власного decode тієї самої image; Pascal probe і маленька Python reference model логують running bit counter поруч із кожним read; перша position, де два logs розходяться, і є місцем bug. Почніть із fixture, який активує якнайменше: flat image 32x32, що використовує лише simple-code path. Спочатку зробіть green його, потім додавайте gradients, odd dimensions і alpha по одному fixture. Вгадувати bit order замість цього — спосіб витратити день

Чесне застереження: reference теж була неправильною. Python model забула читати cache_bits, а її transform loop не доходив до завершення, тому деякі divergence points були втратою sync самою reference decoder, а не Pascal. Те, що reference implementation помилкова, не робить implementation under test правильною, і жодна сторона не отримує benefit of doubt: кожну divergence треба звірити з RFC text. Цей text також беріть із source. Search summaries регулярно псують numeric tables, а 120-entry distance map, 14 predictor modes і color cache multiplier $1e35a7bd мають бути переписані точно

Де Pascal integer division розходиться з C

Color transform VP8L — це 3.5 fixed point із signed deltas, і тут Pascal та C перестають погоджуватися. C зсуває negative integers арифметично, що дає floor; Pascal div truncates toward zero. Для будь-якого negative product вони відрізняються на one, тому inverse color transform зсуває channel на one step per pixel через усю image. Тому HotPDF явно виконує floor у FloorDiv32, а не покладається на div

// C shifts арифметично і floor-ить negatives; Pascal div truncates
// до zero, тому для negative case потрібна 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 між transform element byte і
// color channel byte, обидва спочатку sign-extend
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;

Цей class defect варто назвати, бо він невидимий для будь-якого test, чиї fixtures випадково породжують лише non-negative products, де div і floor збігаються. Також саме тому HotPDF WebP tests перевіряють pixel-exact equality із Pillow decodes тих самих files, а не tolerance: gradients, odd 100x37 size, image 40x40 зі справжнім alpha channel і flat 32x32 image — кожен pixel порівнюється bit for bit. One-step drift проходить perceptual check і провалює bitwise check

Що WebP support навмисно відхиляє

HotPDF декодує перший VP8L chunk WebP file і нічого більше. Lossy VP8 frames, animations і будь-який container, чий matching chunk не VP8L, повертають False з HPDFDecodeWebPLossless, а AddImage перетворює це на exception з іменем file: Failed to decode WebP image (lossless VP8L only). Це свідома boundary, а не oversight: wrong-format file має провалитися там, де caller може pre-convert його, а не створити gray rectangle. Version field має бути 0, transform stack обмежений чотирма entries, і кожне bounds violation піднімає EWebPDecode, який public entry point перетворює на plain False. Decoding під час import — також протилежний напрямок до витягування pictures із opened document, яке проходить через loaded-image path, описаний у витягуванні images із loaded PDF та їхніх decode filters. І будь-який image decoder є parser, якому подають files, створені не вами: якщо WebP assets приходять від customers або public internet, bounds checks тут — floor, а не ceiling, і сильніша відповідь — запуск image codecs в isolated worker process, щоб malformed frame не зміг обвалити host разом із собою

Практичний результат такий: Delphi або C++Builder application тепер може додати WebP assets у PDF так само, як PNG: один call до AddImageFromFile, один call до ShowImage, нічого зайвого в installer. Якщо потрібен решта image та document pipeline навколо цього, HotPDF Delphi PDF component охоплює writing, loading і rendering side з того самого набору units