PDFlibPas 修掉了原生 JBIG2 半调区域解码器里两个独立的故障:v3.539.37 把 HSKIP 跳过掩码按 ITU-T T.88 §6.6.5.1 定义的方式以 HSKIP[ng, mg] 索引;v3.539.38 让经由负 HGX 或 HGY、或经旋转到达负坐标的网格用真正的 floor 移位来放置。这两个版本之前,受影响的半调区域出来是乱的或者错位,而且不报任何错误。两个 bug 都藏在恰好对称或非负的测试数据背后,第二个还牵出 Delphi 与 Free Pascal 一个 JBIG2 之外也咬人的性质:带符号整数上的 shr 是逻辑移位,不是标准默认的算术移位 >>
半调区域是 JBIG2 里最罕见的区域类型,解码器可能处理几千份扫描件都碰不到一张以这种形式编码的加网照片。真碰上时,失败很恶心:文件解析通过,段长度对得上,页面尺寸正确,区域内容却是一堆垃圾
JBIG2 半调区域到底解码出什么?
JBIG2 半调区域是从 pattern dictionary 里取小位图组成的网格,解码器真正的活是给每个网格单元算索引,以及该单元落在哪个像素位置。pattern dictionary 持有 HNUMPATS 个 HPW × HPH 像素的图案。半调区域段随后描述一个 HGW 列 × HGH 行的网格和一幅同尺寸的灰度图,灰度图按 Gray 编码的位平面编码。每个位平面用 generic region 过程在 HGW × HGH 位图上解码,最高位平面在前,各平面合起来给每个单元图案索引
单元放置用带 8 位小数的定点算术。网格原点 HGX、HGY 是一对 32 位值,网格向量 HRX、HRY 描述相邻单元之间的步进,因此网格可以是旋转过的。对网格行 mg、网格列 ng,T.88 §6.6.5 这样算像素位置:
x = (HGX + mg × HRY + ng × HRX) >> 8y = (HGY + mg × HRX − ng × HRY) >> 8
跳过掩码经由可选的 HENABLESKIP 标志进来。标志置位时,§6.6.5.1 构建一个 HGW × HGH 的位图 HSKIP,对图案完全落在区域外的每个单元把 HSKIP[ng, mg] 置 1:即 x + HPW <= 0、x >= HBW、y + HPH <= 0 或 y >= HBH。灰度位平面随后以该掩码作为 generic region 的跳过位图解码,算术解码器对被跳过的单元既不读也不更新上下文。解码器与编码器必须在 HSKIP 的每一位上达成一致,否则两个算术编码器就会失步
为什么转置的 HSKIP 掩码只坑非正方形网格?
跳过掩码写入时坐标换了序,而只有非正方形网格会暴露它,因为正方形网格让每个换序坐标都留在掩码之内。PDFlibPas 的位图用 (column, row) 的像素访问器,构建掩码的代码却传了 (mg, ng),行在前。灰度位平面解码器按 (ng, mg) 正确读取掩码。图案放置循环却按构建时的换序读回,两边倒是一致了,单看放置逻辑的审查也能过。命名陷阱雪上加霜:放置循环里叫 col 的变量迭代网格行,Row 迭代网格列
拿 v3.539.37 的回归案例来说:16 × 8 区域上放 4 × 4 图案的 5 × 3 网格。取 HRX = 1024、HRY = 0,网格列 4 落在 x = 16,网格行 2 落在 y = 8,都在区域外。正确的掩码标出七个单元:整个列 4 和整个行 2。换序的写入却试图在一个只有三行高的掩码里设第 3、4 行的像素,位图 setter 静默丢弃这些越界写入。留下的是列 2 的 0 到 2 行。于是解码器跳过了编码器编码过的两个单元,又解码了编码器跳过的六个单元
算术解码器在这种时候不会失败。它从属于后续单元的比特里多解出像素,上下文读到的邻居全错,第一次不一致之后的每个图案索引都是噪声——这就是症状是整块区域乱掉、而非几个单元错位的原因。在正方形网格上同一个 bug 往往不可见:没有换序坐标越出掩码,而且当区域外单元关于对角线对称时——比如网格向右和向下超出同样数量的单元——转置后的掩码逐位恰是正确的那个。HENABLESKIP 还是可选的,灰度图为 MMR 编码时必须为 0,编码器也很少设置它,所以这个 bug 几乎没有露头的机会。从 v3.539.37 起,构建器写 HSKIP[ng, mg],放置循环按同样的顺序读
负的半调网格偏移为什么会分三层出错?
起点在区域左侧或上方的半调网格,在 PDFlibPas 里砸出三个互不相干的地方,每个故障还把下一个藏起来。T.88 是故意允许这种几何的。编码器若把加网对齐到页面而非区域,或者使用旋转网格,自然会产出被区域裁掉的负单元角。v3.539.38 把三层一起修了,因为单修任何一层只会换个症状
第一层:有符号字段按无符号读
T.88 §7.4.5.1.2 把 HGX 和 HGY 定义为带符号 32 位值,解码器却用读无符号字段的同一个 32 位辅助函数读它们,而那个辅助函数把每个负结果都钳到 0。本该从 HGX = -900 起步的网格被悄悄挪到区域原点。v3.539.38 的回归案例里整幅图矮了两行。这个钳位也解释了另外两个故障为何活得那么久:原点被强行非负之后,负坐标只能经 HRY > 0 的旋转网格出现——y = HGY + mg × HRX − ng × HRY 在后面的网格列上会掉到零以下
第二层:shr 不是 >> 8
T.88 写 >> 8,指的是向负无穷取整的算术移位。解码器把它译成了 shr 8。在 Delphi 和 Free Pascal 里,带符号整数上的 shr 是逻辑移位:移进来的是零。对保存 -512 的 Integer,shr 8 得到 16777214 而不是 -2。本该画在 y = -2、裁到下半部分的图案被发到一千六百万行之下,按区域外丢弃。什么都不崩溃,半调的第一行就这么没了
第三层:比较的是定点值而不是像素
跳过测试比较的是定点值而非像素位置,小数部分非零时两者并不等价。原代码为了绕开逻辑移位,在未移位的值上测 xx + HPW × 256 <= 0,自以为是 T.88 测试的等价形式。取 HGX = -900 和 4 像素图案:-900 + 1024 = 124,是正数,单元不跳过。标准先移位:floor(-900 / 256) = -4,-4 + 4 = 0 满足 x + HPW <= 0,单元完全在区域外,必须跳过。编码器跳了它,解码器解了它,灰度图便像转置掩码那一案一样漂移
v3.539.38 的回归案例在 12 × 10 区域上取 HGX = -900、HGY = -512、HRX = 1024 的 4 × 3 图案为 4 × 4 的网格。网格列落在 x = -4、0、4、8,列 0 完全在外、应进 HSKIP;网格行落在 y = -2、2、6,行 0 应裁到下两行像素而不是丢掉。一层层修,能复现整条故障栈:
| 修好的故障 | 解码出的区域 |
|---|---|
| 无(v3.539.38 之前) | 网格被拽到原点,整幅图矮两行 |
| 只修有符号 HGX / HGY 读取 | 首行网格缺失,其余被跳过测试的漂移搅乱 |
| 有符号读取、floor 移位与像素空间的跳过测试 | 与按 T.88 §6.6.5 算出的页面逐像素一致,也与两个独立参考解码器一致 |
修复是一个辅助函数 HalftoneGridPixel,跳过掩码构建器和放置循环共用。它在 Int64 里累加坐标,让大的 mg × HRX 乘积不会回绕;除以 256 时向负无穷取整;并钳到 ±MaxInt div 2,让损坏的网格无法在后续位图算术里溢出。跳过测试现在拿这些像素值对照 HPW、HPH、HBW 和 HBH,与 §6.6.5.1 的表述一字不差
在 Delphi 里怎么写算术右移?
Delphi 没有算术移位操作符,正确的带符号右移得写成 floor 除法,而普通的 div 不是 floor 除法。div 向零截断。非负值上截断与 floor 一致,负值里恰能整除的也一致——所以 -512 div 256 = -2 在快速测试里看着没问题。其余地方都不一致:-900 div 256 是 -3,floor 是 -4;-1 div 256 是 0,floor 是 -1。带非零小数的 JBIG2 坐标恰恰就是 div 给错像素的那种情形
在 Delphi 的 Win32 与 Win64 编译器上,保存 -512 的 Integer 右移 8 位得到 16777214,保存 -512 的 Int64 得到 72057594037927934。Free Pascal 同样把 shr 定义为逻辑移位,并在它的 System 单元里为算术版本提供了 SarLongint 和 SarInt64,但这两个函数在 Delphi 里不存在,所以两个编译器共享的代码需要自己的辅助函数:
// Floor 除法:A、B 任意符号下都向负无穷取整。
// B 不得为 0,FloorDiv(Low(Integer), -1) 与 div 一样会溢出
function FloorDiv(A, B: Integer): Integer;
begin
Result := A div B;
if (A mod B <> 0) and ((A < 0) <> (B < 0)) then
Dec(Result);
end;
// 算术右移(C 与 T.88 对带符号值的 ">>")。
// Value 为负时,not Value = -Value - 1 非负,逻辑 shr 在此安全,
// 外层的 not 再把结果映射回来
function SarInt32(Value: Integer; Shift: Integer): Integer; // Shift 0..31
begin
if Value >= 0 then
Result := Value shr Shift
else
Result := not ((not Value) shr Shift);
end;
function SarInt64(Value: Int64; Shift: Integer): Int64; // Shift 0..63
begin
if Value >= 0 then
Result := Value shr Shift
else
Result := not ((not Value) shr Shift);
end;
not 这个技巧从不对负数移位,所以不依赖编译器怎么对待符号位,也永不溢出,Low(Integer) 也不例外。两个辅助函数在 Delphi Win32、Delphi Win64 和 Free Pascal x86_64 上对照 Int64 floor 参考验证了数百万个值,0 到 31 的每个移位以及 Low(Integer) 和 High(Integer) 边界都测过。任何碰坐标的单元测试都值得留一条这样的健全性检查:
var
V: Integer;
begin
V := -900;
Writeln(V shr 8); // 16777212 逻辑移位,旧的 bug
Writeln(V div 256); // -3 向零截断
Writeln(FloorDiv(V, 256)); // -4 T.88 的 >> 8 想要的值
Writeln(SarInt32(V, 8)); // -4
end;
Math.Floor(V / 256) 同样返回 -4,但它绕道 Double,对 253 以上的 Int64 值会丢精度,所以整数几何就应该留在整数里
哪些 PDFlibPas 调用会跑半调解码器?
PDFlibPas 用内置渲染器渲染页面时会跑 JBIG2 半调解码器,因为渲染需要像素。RenderPageToFile 和 RenderPageToStream 都经由页面的 JBIG2Decode 图像流走到它,所以重新渲染半调页面是确认 v3.539.38 改变你输出结果的最直接办法。同一个解码器还处理其他 JBIG2 区域类型,见 纯 Pascal 解码器里的 JBIG2 自定义 Huffman 表与 在 Delphi 里解码随机访问的 JBIG2 文件,渲染出的位图则供给 把 PDF 页面渲染成 1-bit 单色之类的转换
uses
SysUtils, PDFlibrary;
var
Lib: TPDFlib;
Page: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('scanned-halftone.pdf', '') <> 1 then
raise Exception.CreateFmt('Load failed, error %d', [Lib.LastErrorCode]);
for Page := 1 to Lib.PageCount do
// 渲染会解码每个 JBIG2 区域,包括半调
if Lib.RenderPageToFile(150, Page, PDF_RENDER_PNG,
Format('page-%.3d.png', [Page])) <> 1 then
Writeln('Page ', Page, ' was not rendered');
finally
Lib.Free;
end;
end.
图像提取走的是另一条路。GetPageImageList 以原生形式返回 JBIG2 图像,SaveImageListItemDataToFile 或 GetImageListItemDataToString 会给你一个由流字节拼出的独立 JBIG2 文件:文件头、JBIG2Globals 数据和围绕页面数据的 end-of-file 段。GetImageListItemIntProperty 的属性 400 对这类表项报告 6。这条路径什么都不解码,所以“提取出的 .jb2 在别的查看器里看着正常、渲染出的页面却是噪声”正是这两个半调 bug 的典型症状:
var
ListID, I: Integer;
begin
Lib.SelectPage(1);
ListID := Lib.GetPageImageList(0);
if ListID = 0 then
Exit;
try
for I := 1 to Lib.GetImageListCount(ListID) do
if Lib.GetImageListItemIntProperty(ListID, I, 400) = 6 then // 独立 JBIG2
Lib.SaveImageListItemDataToFile(ListID, I, 0,
Format('page1-image%d.jb2', [I]));
finally
Lib.ReleaseImageList(ListID);
end;
end;
掩码或色彩转换迫使走渲染回退时,表项回来的是解码后的位图,半调解码器这时确实会跑。图像列表的更多内容见 Delphi PDF 文本、图像与字体提取
速查:JBIG2 半调网格规则
- 跳过掩码按
HSKIP[ng, mg]索引、网格列在前,放置单元的任何地方都按同样顺序读回(T.88 §6.6.5.1,PDFlibPas v3.539.37 修复) - 测试任何半调或网格代码时用非正方形网格加一组不对称的区域外单元,因为正方形网格能把转置索引藏得严严实实
HGX和HGY按带符号 32 位值读取(T.88 §7.4.5.1.2),绝不经过会把负数钳掉的辅助函数- 把标准里的
>> 8译成除以 256 的 floor 除法,不是shr 8,也不是div 256 - 跳过测试在移位后的像素位置上跑;小数部分非零时定点形式就不一样了,
HGX = -900配 4 像素图案就是例证 - 网格坐标在
Int64里累加,交给位图代码前先钳位,损坏的网格就无法溢出 - 文档里有带
HENABLESKIP的半调区域、负网格原点或旋转网格的话,升级到 v3.539.38 或更高
PDFlibPas 从 Delphi 和 C++Builder 渲染、提取并编辑 PDF 文档,其原生 Pascal JBIG2 解码器现在按 T.88 的规定处理半调跳过掩码、负网格原点与旋转网格。功能、版本与试用下载见 PDFlibPas Delphi PDF 库