一个标记为/Predictor 12的流,并不意味着每一行都使用PNG filter 2。HotPDF是面向Delphi和C++Builder的原生VCL PDF组件,它把predictor值10到15当作同一个家族来处理:真正的filter标签,取值0到4,是每个编码行的第一个字节,HPDFDecodePredictor逐行读取并校验这个标签。这个区分正是PDF这个角落里几乎所有bug的共同形态,因为一旦你搞错了,什么都不会报错。滤波链照常运行,光栅尺寸也和你预期的一样,图像出来的结果却是斜向的雪花噪点,或者一个随着每条扫描线越漂越远的渐变。/DecodeParms(ISO 32000-1 §7.4.4)里的五个数字,大多数改变的是字节的含义,而不是字节的长度,所以一个错误的取值产生的是看似合理的乱码,而不是一个错误提示
为什么/Predictor 12不代表每一行都是PNG filter 2?
因为predictor这个数字只是在说"正在使用PNG预测",而不是在说具体是哪种滤波器。PNG编码器为每条扫描线各自选择一种滤波器,PDF的这个filter继承了这个特点,所以predictor值10(None)、11(Sub)、12(Up)、13(Average)、14(Paeth)和15(Optimum)的解码方式完全相同:解码器必须遵循的是每一行开头的那个标签字节。这带来的布局后果和语义本身一样重要。每个编码行的长度都是1 + RowBytes字节,所以输入长度恰好比输出长度多出行数这么多,任何长度不是RowBytes + 1整数倍的流,按定义就是被截断了。HotPDF在碰任何一个字节之前就先检查这个边界,任何大于4的标签都会被以Invalid PNG predictor row tag拒绝,而且它是直接从单一输出缓冲区中读取上一行,而不是另外物化一个二维行数组。filter 1和3在当前行内向左回溯BytesPerPixel个字节,filter 2直接向上读取,filter 4在左、上、左上三者之间执行Paeth选择——这四种滤波器操作的都是已经重建好的输出,这正是为什么"上一行"必须是已解码的行,而绝不能是经过滤波的原始输入
uses
HPDFPredictor;
var
Filtered, Raster: AnsiString;
ErrorText: string;
begin
// /DecodeParms << /Predictor 12 /Colors 3 /BitsPerComponent 8 /Columns 1024 >>
if HPDFTryDecodePredictor(Filtered, 12, 3, 8, 1024,
Int64(1024) * 3 * 8192, Raster, ErrorText) then
ConsumeRaster(Raster)
else
LogStreamDefect('predictor', ErrorText); // no exception, no partial raster
end;
MaxOutputBytes这个参数不是摆设。predictor阶段本质上是一个伪装过的解压阶段,一个恶意或者仅仅是损坏的/Columns值,能把几千字节的输入变成一个数吉字节的分配请求。HotPDF会先用Int64计算每行的位数、每行的字节数和光栅总大小,拒绝任何会溢出的几何尺寸,并遵循调用方传入的上限。只要传入一个从图像字典推导出来的真实上限,失败模式就会变成一条日志消息,而不是客户机器上的一个内存不足对话框
为什么TIFF Predictor 2会破坏4位图像?
因为Predictor 2做的是按样本的水平差分,而不是按字节,而在每个分量1、2或4位的情况下,多个样本会共享同一个字节。常见的实现方式是把字节N-Colors加到字节N上,这在每分量8位时恰好是对的,在其他所有情况下都是悄悄出错的。一次8位RGB扫描能完美解码,然后同一份代码会在生产环境中第一次遇到4位索引图像时把它毁掉
正确的算术运算要在位域内部进行。HotPDF从索引Colors遍历到Colors * Columns - 1逐个样本处理,用(1 shl BitsPerComponent) - 1这个掩码在相应的位移处取出该样本及其同一分量的左邻样本,按这个掩码取模相加,再把结果写回,同时不打扰打包在同一个字节里的其他样本。末尾的处理也很关键:一行会被填充到字节边界,所以最后一个样本之后的填充位必须原样保留,不能被卷入运算。在每分量16位时,每个样本是一对大端字节,加法运算是在这一对字节整体上按$FFFF回绕,而不是在字节之间各自独立进位;在每分量8位时,简单的按字节递推就是对的,步长为Colors,这样红色分量只跟红色分量累加,alpha只跟alpha累加。在所有变体中,一行的第一个像素都是字面值,绝不是差值,而且这个递推在每一行边界都会重新开始——TIFF预测从来不会读取上一行,这正是它和PNG那一家族之间的全部区别
EarlyChange在LZWDecode里到底控制什么?
它控制的是读取器什么时候把码字宽度加宽一位,而只要在这一点上差了一步,后面的一切都会被破坏。HotPDF用一个不变量表达这条规则:在添加一个字典条目之后,下一次读取会在NextCode达到(1 shl CodeSize) - Ord(EarlyChange)时加宽。在/EarlyChange 1(ISO 32000-1 §7.4.4规定的默认值)下,切换会提前一个码字发生;在/EarlyChange 0下,切换恰好在边界处发生。这两种情况在真实文件中都会出现,而比特流本身没有任何东西能告诉你编码器用的是哪一种。状态机的其余部分必须与之步调一致:一个clear码会同时重置码字宽度、位掩码、下一个空闲码以及短语存储区;end-of-information码是按当时正在生效的宽度读取的,而不是按初始的9位读取。HotPDF从InitialCodeSize 9开始,把码字宽度上限设为12,字典条目上限设为4096,并把FillOrder默认为foTop,因为PDF打包码字时是高位在前——foBottom是为那些不这样做的TIFF风格流准备的
uses
HPDFLZW;
var
Decoder: TPDFLZWDecompressor;
Parms: TPDFLZWParms;
Plain: AnsiString;
begin
Decoder := TPDFLZWDecompressor.Create;
try
Decoder.EarlyChange := True; // /EarlyChange 1 is the PDF default
Decoder.FillOrder := foTop; // high-order bit first
Decoder.MaxOutputBytes := 256 * 1024 * 1024;
Decoder.RequireInitialClear := False;
Decoder.RequireEndOfInformation := False;
Parms.Predictor := 12;
Parms.Colors := 3;
Parms.BitsPerComponent := 8;
Parms.Columns := 1024;
Parms.ExpandedTo8Bit := False;
Parms.ColorSpace := 'DeviceRGB';
if Decoder.TryDecompress(RawStreamBytes, Parms, Plain) then
LogDecodeStats(Decoder.PeakCodeSize, Decoder.DictionaryAdds,
Decoder.KwKwKExpansions, Decoder.OutputBytes)
else
LogStreamDefect('lzw', Decoder.LastError);
finally
Decoder.Free;
end;
end;
这些统计数据是为排查问题准备的,不是摆设。当一份文件解码出来的长度是对的、像素却是错的,PeakCodeSize和DictionaryAdds能立刻告诉你,读取器加宽的时机是否和写入器一致。翻转EarlyChange,重新解码一次,比较两次结果:如果数字变了,你一次运行就得到了答案,不用再逐位单步跟踪读取器
KwKwK分支,以及一个流什么时候应该干脆判失败
唯一一个看起来不合法、实际却合法的情况是Code = NextCode,HotPDF的处理方式是在输出这个条目之前先把它构造出来。编码器可能会在同一步里输出一个它正在定义的短语所对应的码字,这种情况在输入包含K w K w K这种模式时就会出现;解码器没法去查这个码字,因为它此刻还不存在,所以必须构造出Previous + First(Previous),把它当作新条目加进去,再输出这个刚刚创建出来的条目。HotPDF会在KwKwKExpansions中统计这类情况,并交叉核对它添加的码字是否正是被请求的那个。任何高于NextCode的值都属于数据损坏,遇到这种情况解码器应该停下来,而不是即兴发挥:HotPDF会在遇到一个"未来"码字、一个指向短语存储区之外的字典前缀、一个已满的字典,以及一个不是字面值的首个码字时报错。有两个严格性开关默认是关闭的,RequireInitialClear和RequireEndOfInformation,这是刻意为之,因为大量生产环境中的PDF会省略开头的clear码,或者数据用完了也没有终止符。在校验你自己的输出时把它们打开,在消费来自野外的文件时把它们保持关闭
在已加载文档这一侧,/DecodeParms实际是在哪里被读取的
HotPDF会在图像流字典上解析/DecodeParms或它的缩写/DP,既接受字典形式也接受数组形式,如果是数组就取最后一个元素,然后把Predictor、Colors、BitsPerComponent、Columns和EarlyChange带入光栅处理路径。数组的情况正是人们容易忘记的地方:一个经过[/ASCII85Decode /FlateDecode]过滤的流会携带一个并行的参数数组,而predictor的设置属于最后一个filter,不是第一个
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned.pdf', '') > 0 then
for I := 0 to Pdf.GetLoadedImageCount - 1 do
if Pdf.GetLoadedImageInfo(I, Info) then
begin
Bmp := Pdf.ExtractLoadedImage(I); // nil when the raster is unusable
if Bmp <> nil then
try
Bmp.SaveToFile(Format('image-%d.bmp', [I]));
finally
Bmp.Free;
end;
end;
finally
Pdf.Free;
end;
end;
这条路径上有一个历史缺陷值得单独说一说,因为这一类bug会反复出现。旧版的带参数Flate处理例程会创建一个解压流,然后从原始的压缩输入中拷贝数据,结果predictor阶段收到的是压缩过的字节,它还尽职尽责地对这些字节做了反预测处理:结果永远是错的,却从来不会报错。现在的代码只从解码器中读取数据,然后再交给共享的predictor处理,而且它会拒绝一个比计算出的尺寸更短的光栅,而不是退回去使用仍处于压缩状态的字节——过去那种退回方式,会把一次解码失败变成一张损坏的位图。同一份predictor实现现在也服务于交叉引用流,如果你同时也在处理对象流与增量更新,这种一致性会很有用;相关的提取机制在配套文章提取已加载图像及其解码filter中有介绍。以DCTDecode或JPXDecode方式到来的图像则完全不会经过predictor;它们携带的是自己独立的压缩像素模型
吞吐量:连续短语存储区对比逐条目字符串
在一个病态输入上做测试,用连续的短语存储区替换逐条目字符串字典,速度快了大约1.61倍:1558 MiB/s对969 MiB/s,这个基准测试中单条最长短语达到了7,370,880字节。这种输入的形态解释了这个差距,因为经典实现往往在两种糟糕的取舍之间二选一。一个由AnsiString值组成的字典,会为最多4096个条目中的每一个都分配并拷贝一份全新的字符串,每个新条目都要把它的父条目整个拷贝一遍;一个前缀/后缀栈能完全避免这种内存开销,但重建每个短语时要沿着链一个字节一个字节地反向遍历再翻转过来,对普通文本来说没问题,但当某个短语长达数兆字节时就会很痛苦。HotPDF把每个短语连续追加进一个按几何级数增长的存储区,通过偏移量和长度对条目做索引,输出一个短语时只需要向输出缓冲区做一次Move。老实说这样做的代价是内存:一个完整保存所有短语的存储区,其大小上限是所有短语长度之和,而不是条目数量,这正是MaxOutputBytes要同时存在于解压器和predictor中的原因。把这个上限从图像字典所声称的光栅大小推导出来,一个撒谎的流就会很快失败
这里展示的LZW解压器、共享predictor以及已加载图像的提取路径,都作为标准版HotPDF Component的一部分提供,适用于Delphi和C++Builder,完整的filter与DecodeParms参考可在产品页查阅