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 组件会通过同一组单元覆盖写入、加载和渲染侧