บทความเทคนิค

WebP เป็น PDF ใน Delphi: ภายใน VP8L Decoder ของ HotPDF

HotPDF 2.747.0 decode image WebP ด้วย VP8L (WebP lossless) decoder ที่เขียนจากศูนย์ด้วย Object Pascal ดังนั้น THotPDF.AddImageFromFile จึงรับ path .webp ได้โดยตรง ไม่ต้องแจกจ่าย libwebp DLL และไม่ต้องเปิด helper process decoder implement RFC 9649 section 3 ครบทั้ง RIFF container walk, canonical prefix code, LZ77 backward reference, color cache และ inverse transform ทั้งสี่แบบ ส่วน VP8 frame แบบ lossy จะถูกปฏิเสธอย่างชัดเจนแทนการ decode ได้เพียงครึ่งเดียว

จุดเริ่มต้นมาจากเรื่องธรรมดา design tool export asset ทุกอย่างเป็น WebP เพราะเป็น default สมัยใหม่ asset ถูกส่งเข้า invoice หรือ catalog generator ที่รองรับ PNG กับ JPEG มานาน แล้วจู่ ๆ input ครึ่งหนึ่งถูก reject วิธีแก้ที่เห็นชัดคือ bind libwebp แล้วจบ แต่ก็เป็นวิธีที่เปลี่ยน self-contained VCL component ให้ต้องมี deployment story

ทำไมจึง implement VP8L แทนการ bind libwebp

HotPDF implement codec ด้วย Pascal เพราะ Delphi component ที่ลูกค้า compile เข้า executable ของตัวเองไม่ควรแอบเพิ่ม runtime DLL dependency native dependency หมายถึงต้องติดตาม binary 32-bit กับ 64-bit ต้อง pin version ต้องอธิบาย code-signing chain ให้คนที่ deploy และต้องเพิ่มไฟล์อีกหนึ่งตัวที่ antivirus บน terminal แบบ locked-down อาจตัดสินใจไม่ชอบ สำหรับ component ที่ขายจุดเด่นว่าใส่เข้า project แล้วใช้ได้ ต้นทุนนี้เป็นเรื่องจริงไม่ใช่เรื่องทฤษฎี อีกครึ่งหนึ่งของเหตุผลคือ VP8L มีขนาดเล็ก เป็น format แบบ prefix-code กับ LZ77 ที่มี inverse transform สี่แบบและ neighborhood distance map 120 รายการ decoder ทั้งหมดใน HPDFWebP.pas มีขนาดไม่ถึง 900 บรรทัด Pascal ภายใน THotPDF.AddImage สาขา WebP อยู่ที่ extension dispatch เดียวกับที่ route .jp2, .j2k, .jpt และ .jpc ไปยัง JPEG 2000 path ซึ่งเป็นตำแหน่งเดียวกับที่อธิบายใน walkthrough ของ การเพิ่ม JPEG 2000 image ลง PDF ใน Delphi caller ที่ต้องการ raw pixel แทน PDF image ไปตรงที่ HPDFDecodeWebPLossless ได้ ซึ่งจะเติม TWebPCardinalArray ที่เป็นค่า $AARRGGBB ตามลำดับ scan-line

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp จะถูก dispatch ไปยัง 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 เลือก convention ตรงข้ามกัน RFC 9649 section 3.2 ระบุชัดว่า bitstream อ่านจาก least significant bit ก่อน reader เริ่มที่ bit 0 ของ byte แล้วเดินขึ้น ส่วน canonical prefix code ที่อยู่ใน stream มาแบบ most significant bit ก่อนจาก root ของ tree ดังนั้น decode walk จึง shift accumulator ไปทางซ้ายแล้ว 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
  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 สามอย่างที่ทำให้ stream เลื่อนโดยไม่รู้ตัว

มี semantic สามอย่างใน RFC 9649 ที่ระบุไว้เพียงครั้งเดียว อ่านผ่านได้ง่าย และแต่ละอย่างเพิ่มหรือลดเพียงหนึ่ง bit ซึ่งก็พอจะทำให้ table ทุกตัวหลังจากนั้นกลายเป็น noise ทั้งสามจุดพบใน HotPDF VP8L decoder และให้ symptom เดียวกัน คือ image ที่ดูเหมือน decode ได้แต่ผิดทุกจุด

  • 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 ใช้ศูนย์ bit RFC 9649 section 3.7.2.1 บอกไว้ตรง ๆ และ canonical walk จะอ่าน bit อย่างเต็มใจแล้วไม่สามารถวางมันลงที่ใดได้ ดังนั้น BuildHuff จึงตรวจ total symbol count เป็น 1 แล้ว mark tree เป็น Single เพื่อ decode symbol เดียวนั้นโดยไม่แตะ reader
  • ค่า cache_bits ที่เป็น 0 หมายถึง color cache size เป็น 0 ไม่ใช่ 1 shl 0 การ shift แบบสะดวกจะให้ 1 ทำให้ green alphabet 256 + 24 + CacheSize กลายเป็น 281 แทน 280 และทุก prefix code table ที่อ่านหลังจากนั้นจะ misalign
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 หมายถึงไม่มี 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: มีเพียง spatially coded (ARGB) image ที่มี
// meta prefix bit ส่วน entropy-coded role จะไม่เขียนมัน
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 และสิ่งเดียวที่แยกมันได้คือ bit position ที่แน่นอนซึ่ง stream หยุดตรงกับ reference

bit-position diffing ให้อะไร

bit-position diffing เปลี่ยนคำถามที่ไร้ประโยชน์ให้เป็นคำถามบรรทัดเดียว ไม่ใช่ ทำไมภาพนี้ผิด แต่เป็น ทำไม stream จึง diverge ที่ bit 81 การเตรียมไม่แพง Pillow เขียน fixture .webp แต่ละตัวพร้อม dump .rgba จากการ decode image เดียวกันของมันเอง Pascal probe กับ Python reference model ขนาดเล็กต่าง log running bit counter ข้างทุก read จุดแรกที่ log สองฝั่งต่างกันคือจุดที่ bug อยู่ เริ่มจาก fixture ที่ทำให้น้อยที่สุด เช่น image แบนขนาด 32x32 ที่ใช้เพียง simple-code path ทำให้ตรงก่อน แล้วค่อยเพิ่ม gradient, dimension แปลก และ alpha ทีละ fixture การเดา bit order แทนวิธีนี้คือทางใช้เวลาทั้งวัน

ต้องพูดอย่างตรงไปตรงมาว่า reference ก็ผิดเหมือนกัน Python model ลืมอ่าน cache_bits และ transform loop ของมันไม่ได้รันจนจบ ดังนั้น divergence บางจุดเป็น reference decoder ที่เสีย sync ไม่ใช่ Pascal การที่ reference implementation ผิดไม่ได้ทำให้ implementation ที่ทดสอบถูก และทั้งสองฝ่ายไม่ได้รับสิทธิ์ให้เดาถูกไว้ก่อน ทุก divergence ต้องตัดสินเทียบกับข้อความใน RFC ให้ดึงข้อความนั้นจาก source ด้วย search summary มักทำให้ numeric table เพี้ยน และ 120-entry distance map, predictor mode 14 แบบ กับ color cache multiplier $1e35a7bd ต้องถอดตรงทุกตัว

จุดที่ Pascal integer division ต่างจาก C

VP8L color transform เป็น 3.5 fixed point ที่มี signed delta และตรงนี้เองที่ Pascal กับ C หยุดให้ผลเหมือนกัน C shift integer ติดลบแบบ arithmetic ซึ่งเท่ากับ floor แต่ Pascal div truncate ไปทางศูนย์ สำหรับ product ติดลบทุกค่า ผลลัพธ์จึงต่างกันหนึ่ง ทำให้ inverse color transform drift หนึ่ง channel step ต่อ pixel ตลอดทั้ง image HotPDF จึงทำ floor อย่าง explicit ใน FloorDiv32 แทนการพึ่ง div

// C shift แบบ arithmetic และ floor เมื่อเป็นค่าติดลบ ส่วน Pascal div truncate
// ไปทางศูนย์ ดังนั้นกรณีติดลบต้องแก้ไขอย่าง explicit
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// delta แบบ 3.5 fixed-point ระหว่าง 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;

ข้อบกพร่องกลุ่มนี้ควรถูกเรียกชื่อให้ชัด เพราะ test ใดก็ตามที่ fixture บังเอิญสร้าง product ที่ไม่ติดลบจะมองไม่เห็น เนื่องจาก div กับ floor ให้ผลเหมือนกัน และนี่เป็นเหตุผลที่ HotPDF WebP test assert pixel-exact equality กับ Pillow decode ของไฟล์เดียวกันแทน tolerance ทั้ง gradient, ขนาดแปลก 100x37, image 40x40 ที่มี alpha channel จริง และ image แบน 32x32 ถูกเทียบทุก pixel แบบ bit ต่อ bit drift เพียงหนึ่งขั้นอาจผ่าน perceptual check แต่ fail bitwise check

สิ่งที่ WebP support ตั้งใจไม่รับ

HotPDF decode เฉพาะ VP8L chunk แรกของไฟล์ WebP และไม่ทำอย่างอื่น lossy VP8 frame, animation และ container ใดก็ตามที่ matching chunk ไม่ใช่ VP8L จะคืน False จาก HPDFDecodeWebPLossless และ AddImage จะเปลี่ยนเป็น exception ที่ระบุชื่อไฟล์ว่า Failed to decode WebP image (lossless VP8L only) นี่คือ boundary ที่ตั้งใจ ไม่ใช่สิ่งที่ลืมทำ ไฟล์ผิด format ควร fail ตรงจุดที่ caller pre-convert ได้ แทนการสร้างสี่เหลี่ยมสีเทา version field ต้องเป็น 0 transform stack จำกัดไม่เกินสี่ entry และ bounds violation ทุกตัวจะ raise EWebPDecode ซึ่ง public entry point จะแปลงเป็น False การ decode ตอน import ยังเป็นทิศทางตรงข้ามกับการดึงภาพออกจาก document ที่คุณเปิด ซึ่งใช้ loaded-image path ตามที่อธิบายใน การ extract image จาก loaded PDF และ decode filter ของมัน และ image decoder ทุกตัวคือ parser ที่รับ file ที่คุณไม่ได้สร้าง หาก WebP asset มาจากลูกค้าหรือ internet สาธารณะ bounds check ที่นี่เป็นเพียงพื้น ไม่ใช่เพดาน และคำตอบที่แข็งแรงกว่าคือ รัน image codec ใน isolated worker process เพื่อไม่ให้ malformed frame ลาก host ล่มไปด้วย

ผลในทางปฏิบัติคือแอป Delphi หรือ C++Builder ใส่ WebP asset ลง PDF ได้เหมือนใส่ PNG หนึ่ง call ไปยัง AddImageFromFile หนึ่ง call ไปยัง ShowImage และไม่ต้องเพิ่มอะไรใน installer หากต้องการ image และ document pipeline ส่วนที่ล้อมรอบฟีเจอร์นี้ HotPDF Delphi PDF component ครอบคลุมทั้งการเขียน การโหลด และการ render จาก unit set เดียวกัน