مقاله فنی

WebP به PDF در Delphi: داخل decoder نوع VP8L در HotPDF

HotPDF 2.747.0 تصویرهای WebP را با decoder نوع VP8L یعنی WebP lossless که از ابتدا با Object Pascal نوشته شده decode می‌کند؛ بنابراین THotPDF.AddImageFromFile مستقیماً path دارای پسوند .webp را می‌پذیرد، بدون اینکه libwebp DLL برای توزیع یا helper process برای launch لازم باشد. decoder، بخش 3 از RFC 9649 را کامل پیاده می‌کند: walk کردن RIFF container، prefix codeهای canonical، backward referenceهای LZ77، color cache و هر چهار inverse transform. frameهای lossy VP8 به‌جای decode ناقص، با خطای صریح رد می‌شوند

محرک این کار کاملاً معمولی بود. یک design tool همه assetها را به WebP export می‌کند چون default مدرن است، assetها وارد invoice یا catalog generatorی می‌شوند که یک دهه PNG و JPEG را بدون مشکل خورده است و ناگهان نیمی از inputها reject می‌شوند. fix بدیهی این است که libwebp را bind کنید و جلو بروید. همین fix بدیهی هم component خودبسنده VCL را به چیزی تبدیل می‌کند که باید برای آن deployment story تعریف کنید

چرا VP8L را پیاده کنیم، نه اینکه به libwebp bind شویم؟

HotPDF codec را با Pascal پیاده می‌کند، چون Delphi componentی که customerها در executable خودشان compile می‌کنند نباید بی‌سروصدا یک runtime DLL به دست آورد. dependency بومی یعنی باید binary سی‌ودوبیتی و شصت‌وچهاربیتی را track کنید، version را pin کنید، زنجیره code signing را برای مسئول deployment توضیح دهید و یک file دیگر هم داشته باشید که antivirus روی terminal قفل‌شده شاید آن را نپسندد. برای componentی که مزیت اصلی‌اش این است که آن را داخل project می‌اندازید و کار می‌کند، این هزینه واقعی است، نه نظری. نیمه دیگر استدلال این است که VP8L کوچک است: formatی از prefix code و LZ77 با چهار inverse transform و map فاصله همسایگی 120تایی؛ کل decoder در HPDFWebP.pas کمتر از 900 خط Pascal دارد. داخل THotPDF.AddImage، branch مربوط به WebP در همان extension dispatch قرار دارد که از قبل .jp2، .j2k، .jpt و .jpc را به مسیر JPEG 2000 می‌فرستد؛ پس plumbing از قبل آماده بود و همان نقطه‌ای است که در راهنمای افزودن imageهای JPEG 2000 به PDF در Delphi توضیح داده شده است. callerهایی که به‌جای PDF image، raw pixel می‌خواهند می‌توانند مستقیماً سراغ HPDFDecodeWebPLossless بروند که arrayای از نوع TWebPCardinalArray با valueهای $AARRGGBB را به ترتیب scan-line پر می‌کند

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp به decoder داخلی VP8L dispatch می‌شود و DLL در کار نیست
    Idx := Pdf.AddImageFromFile('product-shot.webp', icFlate);
    Pdf.CurrentPage.ShowImage(Idx, 50, 500, 240, 180, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

چرا bitstream از نوع VP8L هم‌زمان در دو جهت خوانده می‌شود؟

چون ترتیب bit در container و ترتیب bit در prefix code مستقل از هم مشخص شده‌اند و VP8L برای آن‌ها conventionهای مخالف انتخاب می‌کند. RFC 9649 بخش 3.2 صریح می‌گوید bitstream از least significant bit اول خوانده می‌شود: reader از bit 0 یک byte شروع می‌کند و به سمت بالا می‌رود. prefix codeهای canonical که داخل همین stream حمل می‌شوند از most significant bit اول و از root درخت می‌آیند؛ بنابراین decode walk، accumulator را به چپ shift می‌کند و هر bit تازه را در پایین OR می‌کند. در نتیجه reader و code walk درون همان loop در دو جهت مخالف حرکت می‌کنند، چیزی که هر بار دوباره خواندن code شبیه 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، مطابق RFC 9649 3.2
  Inc(BitPos);
  if BitPos = 8 then
  begin
    BitPos := 0;
    Inc(BytePos);
  end;
end;

// canonical walk در جهت دیگر اجرا می‌شود: نخستین bit خارج‌شده از stream
// مهم‌ترین 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 که stream را بی‌سروصدا از sync خارج می‌کنند

سه semantic در RFC 9649 دقیقاً یک بار گفته شده‌اند، به‌راحتی از چشم رد می‌شوند و هرکدام یک bit کم یا زیاد می‌کنند؛ همین برای تبدیل همه tableهای بعدی به noise کافی است. هر سه مورد در decoder مربوط به HotPDF VP8L پیدا شدند و هر سه یک symptom دارند: تصویر ظاهراً معقولی که همه‌جا غلط است

  • یک image کدگذاری‌شده با entropy که در role اصلی نیست، اصلاً meta-prefix bit نمی‌نویسد. ABNF مربوط به entropy-coded-image این item را ندارد، پس خواندن یک bit، stream را یک bit از sync خارج می‌کند. HotPDF برای خود entropy image، داده‌های predictor و color transform و palette مربوط به color-indexing مقدار AllowMeta = False می‌فرستد
  • یک prefix code تک‌برگ، صفر bit مصرف می‌کند. RFC 9649 بخش 3.7.2.1 این را مستقیم می‌گوید و canonical walk با میل خودش یک bit می‌خواند و بعد نمی‌تواند آن را در جایی قرار دهد؛ بنابراین BuildHuff total symbol count برابر 1 را تشخیص می‌دهد، tree را Single علامت می‌زند و همان یک symbol را بدون دست زدن به reader decode می‌کند
  • مقدار cache_bits برابر 0 یعنی اندازه color cache برابر 0 است، نه 1 shl 0. shift راحت مقدار 1 می‌دهد و باعث می‌شود alphabet سبز 256 + 24 + CacheSize به‌جای 280 برابر 281 شود و هر prefix code table که بعد از آن خوانده می‌شود misalign شود
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: فقط image با کدگذاری فضایی (ARGB) meta prefix bit دارد؛
// roleهای entropy-coded هرگز آن را نمی‌نویسند
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، 81 و 89 ظاهر شدند. هدف از گفتن این عددها همین است. هیچ‌کدام به شکل off-by-one خودشان را معرفی نکردند؛ هرکدام به‌صورت تصویری ظاهر شدند که تا پایان decode شد و شبیه static به نظر می‌رسید و تنها چیزی که آن‌ها را از هم جدا می‌کرد position دقیق bit بود که stream در آن با reference دیگر هم‌عقیده نمی‌ماند

diff کردن position مربوط به bit چه فایده‌ای دارد؟

bit-position diffing یک سؤال بی‌فایده را به سؤالی یک‌خطی تبدیل می‌کند: نه اینکه چرا این تصویر اشتباه است، بلکه اینکه چرا stream در bit 81 diverge شد. setup ارزان است. Pillow هر fixture دارای .webp و یک dump از نوع .rgba از decode خودش برای همان image می‌نویسد؛ یک Pascal probe و یک reference model کوچک Python نیز کنار هر read، bit counter در حال پیش‌روی را log می‌کنند؛ نخستین positionی که دو log در آن اختلاف دارند همان جایی است که bug زندگی می‌کند. با fixtureی شروع کنید که حداقل چیز ممکن را exercise کند: یک image تخت 32x32 که فقط مسیر simple-code را می‌گیرد. آن را سبز کنید، سپس gradient، dimensionهای odd و alpha را هر بار با یک fixture اضافه کنید. حدس زدن ترتیب bit راهی برای از دست دادن یک روز است

نکته صادقانه این است که reference هم اشتباه بود. model پایتون خواندن cache_bits را فراموش کرده بود و loop مربوط به transform آن تا پایان اجرا نمی‌شد؛ بنابراین بعضی نقطه‌های divergence، خارج شدن reference decoder از sync بودند، نه Pascal. اشتباه بودن reference implementation، implementation تحت test را درست نمی‌کند و هیچ‌کدام از دو طرف benefit of doubt نمی‌گیرند: هر divergence باید با متن RFC داوری شود. آن متن را هم از source بگیرید. summaryهای search معمولاً tableهای عددی را خراب می‌کنند و map فاصله 120تایی، 14 mode مربوط به predictor و multiplier مربوط به color cache یعنی $1e35a7bd باید دقیقاً transcription شوند

تقسیم صحیح عددی Pascal کجا از C جدا می‌شود؟

color transform در VP8L، fixed point از نوع 3.5 با signed delta است و همین‌جا Pascal و C دیگر هم‌نظر نیستند. C، integer منفی را به‌صورت arithmetic shift می‌کند که floor می‌شود؛ div در Pascal به سمت صفر truncate می‌کند. برای هر product منفی این دو یک واحد فرق دارند و inverse color transform در کل image، به‌ازای هر pixel یک channel step drift می‌کند. به همین دلیل HotPDF به‌جای تکیه بر div، floor را در FloorDiv32 صریح پیاده می‌کند

// C به‌صورت arithmetic shift می‌کند و برای منفی‌ها floor می‌گیرد؛ Pascal div
// به سمت صفر truncate می‌کند، پس case منفی به correction صریح نیاز دارد
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 بین byte مربوط به transform element و
// byte مربوط به color channel؛ هر دو ابتدا 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;

نام‌گذاری این دسته از defectها ارزش دارد، چون هر testی که fixture آن اتفاقاً product منفی تولید نکند، این bug را پنهان می‌کند؛ جایی که div و floor برابرند. همین موضوع دلیل آن است که testهای HotPDF WebP، equality دقیق pixel را در برابر decodeهای Pillow از همان file assert می‌کنند، نه tolerance: gradient، اندازه odd برابر 100x37، image چهل‌در‌چهل با alpha channel واقعی و image تخت 32x32؛ هر pixel bit به bit مقایسه می‌شود. drift یک‌مرحله‌ای از perceptual check عبور می‌کند و bitwise check را fail می‌کند

پشتیبانی WebP عمداً از چه چیزهایی خودداری می‌کند؟

HotPDF نخستین chunk از نوع VP8L یک file WebP را decode می‌کند و هیچ چیز دیگری را نه. frameهای lossy VP8، animationها و هر containerی که chunk متناظر آن VP8L نباشد، از HPDFDecodeWebPLossless مقدار False برمی‌گردانند و AddImage آن را به exceptionای تبدیل می‌کند که نام file را هم می‌گوید: Failed to decode WebP image (lossless VP8L only). این مرزی عمدی است، نه oversight: file با format اشتباه باید جایی fail شود که caller بتواند آن را از قبل convert کند، نه اینکه rectangle خاکستری تولید شود. field مربوط به version باید 0 باشد، transform stack به چهار entry محدود شده و هر bounds violation، EWebPDecode ایجاد می‌کند که public entry point آن را به False ساده تبدیل می‌کند. decode در زمان import، جهت مخالف بیرون کشیدن picture از documentی است که باز کرده‌اید؛ آن کار از مسیر loaded-image می‌گذرد که در استخراج image از PDF بارگذاری‌شده و decode filterهای آن توضیح داده شده است. همچنین هر image decoder یک parser است که fileهایی را می‌گیرد که شما نساخته‌اید: اگر WebP assetها از customer یا اینترنت عمومی می‌آیند، bounds checkهای اینجا کف ایمنی هستند نه سقف آن و پاسخ قوی‌تر اجرای codecهای image در یک worker process ایزوله است تا frame malformed نتواند host را نیز با خود پایین بکشد

نتیجه عملی این است که application دلفی یا C++Builder حالا می‌تواند assetهای WebP را همان‌طور که PNG را داخل PDF می‌گذارد، قرار دهد: یک call به AddImageFromFile، یک call به ShowImage و هیچ چیز اضافی در installer. اگر بقیه pipeline مربوط به image و document را که دور این قابلیت قرار دارد می‌خواهید، HotPDF Delphi PDF component سمت writing، loading و rendering را از همان مجموعه unit پوشش می‌دهد