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 元件會透過同一組單元涵蓋寫入、載入和渲染端