技術文章

Delphi 使用 HotPDF 將 WebP 轉成 PDF

HotPDF 2.747.0 使用從頭以 Object Pascal 撰寫的 VP8L(WebP 無損)解碼器解碼 WebP 影像,因此 THotPDF.AddImageFromFile 可以直接接受 .webp 路徑,不需要隨程式分發 libwebp DLL,也不需要啟動輔助程序。解碼器完整實作 RFC 9649 第 3 節:RIFF 容器遍歷、規範前綴碼、LZ77 反向參照、顏色快取以及四種逆轉換。遇到有損 VP8 框架時會明確拒絕,而不是半解碼

起因很普通。設計工具將每個素材都匯出為 WebP,因為這是現代預設格式;素材進入已經處理 PNG 和 JPEG 多年的發票或目錄產生器;突然之間,一半輸入都被拒絕。顯而易見的修復是繫結 libwebp,然後繼續。這個顯而易見的修復也會把自包含的 VCL 元件變成需要額外部署說明的東西

為什麼要實作 VP8L,而不是繫結 libwebp

HotPDF 用 Pascal 實作編解碼器,是因為客戶會把 Delphi 元件編譯進自己的可執行檔,元件不能悄悄取得執行階段 DLL。原生相依性意味著要追蹤 32 位元和 64 位元二進位檔、固定版本、向部署執行者解釋程式碼簽署鏈,還要增加一個鎖定終端上的防毒軟體可能不喜歡的檔案。對主要賣點是加入專案即可工作的元件來說,這是真實成本而不是理論成本。另一方面,VP8L 很小:它是帶四種逆轉換和 120 項鄰域距離映射的前綴碼加 LZ77 格式,HPDFWebP.pas 中的整個解碼器不到 900 行 Pascal。在 THotPDF.AddImage 中,WebP 分支位於現有副檔名分派的同一位置,該位置已經把 .jp2.j2k.jpt.jpc 路由到 JPEG 2000 路徑,Delphi 中向 PDF 新增 JPEG 2000 影像的介紹也描述了這條路徑。想要原始像素而不是 PDF 影像的呼叫方,可以直接使用 HPDFDecodeWebPLossless,它會按掃描線順序填入一個 TWebPCardinalArray,其中的值為 $AARRGGBB

uses
  HPDFDoc, HPDFWebP;

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog.pdf';
    Pdf.BeginDoc;
    // .webp 會分派到內建 VP8L 解碼器,不涉及 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 位元串流為什麼會同時朝兩個方向讀取

因為容器位元順序和前綴碼位元順序是分別規定的,而 VP8L 為它們選擇了相反慣例。RFC 9649 第 3.2 節明確說明,位元串流按最低有效位元優先讀取:讀取器從一個位元組的第 0 位元開始向上移動。位元串流內部攜帶的規範前綴碼則按最高有效位元優先到達,樹根在前,因此解碼遍歷會將累加器左移,把每個新位元或入底部。於是,同一個迴圈中讀取器和程式碼遍歷朝相反方向執行,每次重新閱讀這段程式碼時都會像是發現一個 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;

// 規範遍歷朝另一個方向執行:從位元串流取出的第一個位元
// 是程式碼的最高有效位元
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 細節

RFC 9649 中有三個語意只被精確寫出一次,很容易讀過去,而每一個都會多消耗或少消耗一個位元,足以讓後面的所有表格變成雜訊。HotPDF VP8L 解碼器中發現了這三個問題,它們產生的症狀也完全相同:影像看起來像是完成了解碼,卻到處都是錯誤

  • 非主角色中的熵編碼影像根本不會寫 meta 前綴位元。entropy-coded-image 的 ABNF 中沒有這一項,因此讀取一個位元會讓位元串流錯位。HotPDF 對熵影像本身、預測器和顏色轉換資料以及顏色索引調色盤都傳入 AllowMeta = False
  • 只有一個葉子的前綴碼消耗零個位元。RFC 9649 第 3.7.2.1 節直接說明了這一點;規範遍歷如果貿然讀取一個位元,就會讀到無法放置的內容,因此 BuildHuff 會偵測總符號數為 1 的情況,將樹標記為 Single,不碰讀取器就解碼這個符號
  • cache_bits 為 0 時表示顏色快取大小是 0,而不是 1 shl 0。方便的移位會得到 1,使綠色字母表 256 + 24 + CacheSize 從 280 變成 281,之後讀取的每個前綴碼表都會錯位
CacheBits := 0;
CacheSize := 0;                        // cache_bits = 0 確實表示沒有快取
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:只有空間編碼的(ARGB)影像攜帶 meta 前綴位元;
// 熵編碼角色從不寫入該位元
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);

在啟動階段使用的夾具上,三個問題依次出現在第 47、81 和 89 位元。這些數字正是本節的重點。三者沒有一個表現為明顯的 off-by-one;它們都表現為一張完成解碼、看起來像靜電雜訊的影像,唯一能區分它們的,是位元串流從哪個精確位元位置開始不再與參考實作一致

按位元位置比較能帶來什麼

按位元位置比較可以把一個無用的問題變成一句話就能問的問題:不是為什麼這張圖是錯的,而是為什麼位元串流在第 81 位元發生了分歧。準備工作很便宜。Pillow 為每個 .webp 夾具寫出檔案以及對同一影像的解碼 .rgba 傾印;Pascal 探針和小型 Python 參考模型都會在每次讀取旁邊記錄執行中的位元計數;兩份日誌首次出現差異的位置就是 bug 所在。應從盡可能簡單的夾具開始:一個只走簡單程式碼路徑的純色 32×32 影像。先讓它通過,再一次只增加一種因素,逐步加入漸層、奇數尺寸和透明度。改為猜測位元順序,只是在浪費一天

誠實的注意事項是,參考實作也曾經錯過。Python 模型忘記讀取 cache_bits,其轉換迴圈也沒有執行完成,因此有些分歧點其實是參考解碼器失去同步,而不是 Pascal 解碼器的問題。參考實作錯誤,並不會讓被測實作自動正確;兩邊都沒有理由獲得預設信任:每個分歧都必須依據 RFC 原文裁定,而且原文也要從來源取得。搜尋摘要經常會弄錯數字表,120 項距離映射、14 種預測模式以及顏色快取乘數 $1e35a7bd 都必須逐字轉錄

Pascal 的整數除法在哪裡偏離 C

VP8L 顏色轉換是帶符號增量的 3.5 定點運算,這正是 Pascal 和 C 開始不一致的地方。C 對負整數執行算術移位,會向下取整;Pascal 的 div 向零截斷。對任何負乘積而言,二者相差一,因此逆顏色轉換會在整張影像上每個像素每個通道累積一步偏移。HotPDF 因此在 FloorDiv32 中明確實作向下取整,而不依賴 div

// C 會執行算術移位並對負數向下取整;Pascal div 向零截斷,
// 因此負數情況需要明確修正
function FloorDiv32(V: Integer): Integer;
begin
  Result := V div 32;
  if (V < 0) and (V mod 32 <> 0) then
    Dec(Result);
end;

// 轉換元素位元組與顏色通道位元組之間的 3.5 定點增量,
// 兩者都先進行符號擴展
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;

值得給這類缺陷命名,因為任何測試夾具剛好只產生非負乘積時,它就完全不可見,而在這種情況下 div 與向下取整是一致的。這也是 HotPDF WebP 測試針對同一檔案的 Pillow 解碼結果斷言像素完全相等,而不是使用容差的原因:漸層、奇數尺寸 100×37、帶真實 alpha 通道的 40×40 影像以及純色 32×32 影像,都逐像素進行位元層級比較。一步偏移可以通過感知檢查,卻會在位元層級檢查中失敗

WebP 支援有意拒絕哪些內容

HotPDF 只解碼 WebP 檔案的第一個 VP8L 區塊,不處理其他內容。有損 VP8 框架、動畫,以及匹配區塊不是 VP8L 的任何容器,都會讓 HPDFDecodeWebPLossless 回傳 False,AddImage 再把它轉換成包含檔案名稱的例外:Failed to decode WebP image (lossless VP8L only)。這是有意設定的邊界,而不是疏忽:格式錯誤的檔案應該在呼叫方還能預轉換的地方失敗,而不是產生一個灰色矩形。版本欄位必須為 0,轉換堆疊最多四項,任何邊界違規都會擲出 EWebPDecode,公開入口會將其轉換成普通 False。匯入時解碼也與從已開啟文件中取回影像的方向相反,後者會經過從已載入 PDF 擷取影像及其解碼過濾器所描述的路徑。而且任何影像解碼器都是解析你沒有建立的檔案的解析器:如果 WebP 素材來自客戶或公共網際網路,這裡的邊界檢查只是底線而不是上限,更強的方案是在隔離的工作程序中執行影像編解碼器,讓格式錯誤的框架無法把宿主程序一起拖垮

實際結果是,Delphi 或 C++Builder 應用程式現在可以像放入 PNG 一樣將 WebP 素材放入 PDF:呼叫一次 AddImageFromFile,再呼叫一次 ShowImage,安裝程式不需要任何額外內容。如果你想了解周圍完整的影像和文件流程,HotPDF Delphi PDF 元件會透過同一組單元涵蓋寫入、載入和渲染端