技术文章

Delphi 中的 CCITTFaxDecode:非压缩模式与受损行

HotPDF 这个原生 Delphi PDF 组件解码 CCITTFaxDecode 图像时,完整支持 T.4/T.6 非压缩模式扩展,也支持由 DamagedRowsBeforeError 驱动的受损行恢复。非压缩模式只能由以 111 结尾的扩展码进入,受损行修复只对设置了 EndOfLine 的 Group 3 数据生效。其余情况一律干净地失败,而不是产出一页歪斜的内容

传真编码图像是 PDF 图像解码的长尾。你见到的大多数 CCITT 流出自扫描仪驱动或文档采集管线,它们只会输出朴素的 Group 4,一个处理了常见情形的解码器能通过你扔给它的所有测试。然后某台十五年前的传真服务器的归档到了手,一页从第 400 行起渲染成斜纹噪声,你才发现 ITU-T 建议书里没人实现的那两部分,恰好就是这份文件用到的两部分。本文按 HotPDF 的处理方式讲清这两部分,也讲清解码器哪些地方会在看起来正常的同时悄悄出错

哪些 CCITTFaxDecode 参数真正改变位流

DecodeParms 字典里有四个条目改变解码器切分位流的方式:K、EndOfLine、EncodedByteAlign 和 DamagedRowsBeforeError。ISO 32000-1 §7.4.6(表 11)定义了它们,任何一项弄错,第一个错位点之后的每一行都会错位。BlackIs1、Columns、Rows 和 EndOfBlock 同样重要,但它们影响的是极性、几何尺寸和解码停止位置,而不是行边界怎么找

  • K 选择编码方式:负数是纯二维 Group 4(T.6),零是一维 Modified Huffman(Group 3 1D),正数是混合 Group 3 2D,此时每个 EOL 后面的标记位说明下一行是 1D 还是 2D
  • EndOfLine 表示每个 Group 3 行前面都有一个 EOL 码,即十一个零位跟一个一
  • EncodedByteAlign 表示每个编码行都从字节边界开始,解码器必须先丢弃填充位再读它
  • DamagedRowsBeforeError 是解码器在放弃之前可容忍的损坏行数,默认为 0

调用 THotPDF.ExtractLoadedImage 或 THotPDF.DecodeLoadedImageStream 时,HotPDF 会从图像字典里读取上述全部参数。有一处刻意偏离规范的行为值得知道:Columns 缺失时,HotPDF 回退到图像的 Width 而不是规范默认的 1728;Rows 缺失时则用 Height。在 2480 像素的 A4 扫描件上省略 Columns 的生成器是错的,但这种情况太常见,宁可相信图像几何尺寸换回一页可用的内容,而不是死守字面默认值换回一堆乱码

为什么 000000 不能当成块结束

在二维行里,连续六个前导零位是有歧义的,把六个零当成 EOFB 开头的解码器,会把一次合法的非压缩模式进入误读成图像结束。Group 4 行与行之间没有 EOL;零多的码只有两种:EOFB(两个 EOL 背靠背),以及 ITU-T T.4 和 T.6 的扩展码。两者都以 000000 开头,分歧在第七位

HotPDF 的 CCITT 解码器逐位解析这个前缀。第七位是 1,表示接下来三位是扩展码,完整模式是 0000001xxx;只有 xxx = 111 进入非压缩模式,其他扩展值一律按畸形处理,因为 PDF 传真滤镜用不上它们。第七位是 0 则只可能还在 EOL 中间,于是解码器接着要求再有四个零位和一个收尾的 1,总共十一个零加一个一,才报告 EOFB。比这短或比这长都是错误,不是宽容的图像结束

宽容版本很诱人,因为它让一整类坏文件看起来没事:解码提前停下,你拿到一张短一点的图像,还没有异常。但它也恰恰是那个会在第一段非压缩数据处截断合法传真的 bug——扫描表单上,那通常是一个笔迹密集的签名或一枚印章。HotPDF 选择严格解读,所以图像变短就意味着编码器真的在那里结束了

非压缩模式怎么编码像素

非压缩模式用逐字的像素图案取代游程编码,每个码字最多携带五个像素,直到遇到退出码切回去。一维行用 12 位码 000000001111 进入;二维行用上文那个 10 位扩展 0000001111。编码器会在半调似的区域切进这个模式——那里游程只有一两个像素,记游程比直接记像素还贵

模式内部,码字靠前导零计数。零到四个零跟一个 1,表示同样数量的白像素再加一个黑像素。五个零跟一个 1,表示五个白像素,仅此而已。六到十个零跟一个 1 是退出码:超过六个的零额外发出至多四个白像素,退出码后面的那一位——标记位——在普通游程编码恢复时设定下一段游程的颜色

{ 非压缩模式码字(ITU-T T.4 / T.6)
  W = 白像素,B = 黑像素,T = 标记位

  1            B
  01           W B
  001          W W B
  0001         W W W B
  00001        W W W W B
  000001       W W W W W
  0000001T     退出,之后以颜色 T 恢复游程
  00000001T    W,退出
  000000001T   W W,退出
  0000000001T  W W W,退出
  00000000001T W W W W,退出
  超过十个零:畸形 }

标记位是微妙之处。游程编码是有状态的:1D 行白黑游程交替,2D 行则跟踪当前变化元素 a0 的颜色。退出码之后,解码器必须按标记指定的颜色恢复,这个颜色可能与最后一个字面像素相同,也可能不同。HotPDF 解码器在标记与自己跟踪的颜色不一致时,会在当前位置记下一次颜色变化,让下一段游程从正确的颜色起步。无视标记位,这一行剩下的部分就会颜色反转;而在 2D 模式下,后面每一行都会通过参考行继承这份损伤

DamagedRowsBeforeError 能恢复什么、不能恢复什么

DamagedRowsBeforeError 让 HotPDF 可以替换损坏的行并继续解码,但仅在 K 为 0 或正数、且 EndOfLine 为真时。这两个条件不是拍脑袋定的。恢复需要一种办法,在位流变得不可理喻之后找到下一行的起点,而 CCITT 流里唯一可靠的地标就是 EOL 码。Group 4 数据没有 EOL,不强制 EOL 的 Group 3 数据也无法保证有,所以这两种情况下,无论参数取什么值,损坏行都会终结解码

某行解码失败时,HotPDF 解码器向前扫描到下一个 EOL——至少十一个零位跟一个一——并替换一行替代行。好行之后的第一条损坏行,用前一行的副本替换,在扫描文本页上通常接近到看不出来。紧跟着另一条损坏行的损坏行,则改用全白行替换,因为复制副本会把一行文字涂抹成一道高黑条。替代行随后成为下一个 2D 行的参考行,这正是修复后混合模式流还能继续解码的原因

有两个边界容易漏掉。预算在整个图像上累计,不是每段坏行各自算,所以 DamagedRowsBeforeError 3 总共容忍三条损坏行,第四条就解码失败。而且 HotPDF 里解码失败是全有或全无:THotPDF.ExtractLoadedImage 返回 nil,而不是一张底边参差的位图。THPDFLoadedImageInfo.Decodable 报告的是滤镜受支持,不是这条流完好无损,所以位图一定要查 nil

为什么扫描必须停在 EOL 的起始处

重新同步扫描把读取器停在 EOL 的第一个零位上,而不是越过它。解码器只有一个取帧循环,负责消费 EOL、检查结束块的连续双 EOL、读取混合模式标记位。如果恢复扫描把 EOL 本身吞掉了,这个循环就会看到一行没有 EOL 的数据,把它也当损坏行,白白烧掉第二份预算还丢掉一行好数据。更糟的是,标记位被跳过意味着 2D 行被当成 1D 解码,或者反过来。1D 游程解码器在行中撞上意外 EOL 时也遵循同一规则:它回退到游程码开始的地方,让标记留在原地给恢复扫描用

盘点已加载 PDF 里的 CCITT 图像

信任一批归档传真之前,先列出哪些图像用了 CCITTFaxDecode、哪些解码失败。THotPDF.GetLoadedImageInfo 不解码任何东西就能给你几何信息和滤镜名,THotPDF.ExtractLoadedImage 则告诉你这条流活没活下来。图像分发器如何路由各个滤镜的全貌,见提取已加载 PDF 图像及其解码滤镜指南

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('fax-archive.pdf', '') <= 0 then
      Exit;
    for I := 0 to Pdf.GetLoadedImageCount - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if Pos('CCITT', UpperCase(String(Info.Filter))) = 0 then
        Continue;
      Bmp := Pdf.ExtractLoadedImage(I);   // 流损坏过重时为 nil
      try
        if Bmp = nil then
          Writeln(Format('obj %d: %dx%d CCITT failed to decode',
            [Info.ObjectNumber, Info.Width, Info.Height]))
        else
          Bmp.SaveToFile(Format('fax_%d.bmp', [I]));
      finally
        Bmp.Free;                         // 位图归调用方所有
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

对失败的那些,把证据留住。THotPDF.ExtractLoadedImageToStream 以默认的 lismEncoded 模式原样写出存储的字节,这正是你想交给第二个解码器、包进 TIFF 容器检查或附在 bug 报告里的东西。重新编码一份受损传真,只会把损伤位置藏起来

procedure SaveRawFax(Pdf: THotPDF; ImageIndex: Integer; const FileName: string);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if not Pdf.ExtractLoadedImageToStream(ImageIndex, Fs, lismEncoded) then
      raise Exception.CreateFmt('Image %d has no stream data', [ImageIndex]);
  finally
    Fs.Free;
  end;
end;

如果你想自己遍历图像流,经由 THotPDF.CollectLoadedImageStreams 拿到列表后,把每个 THPDFStreamObject 传给 THotPDF.DecodeLoadedImageStream,得到的就是同一条 CCITT 路径、同一套参数和恢复规则。释放返回的列表,但别释放里面的流对象——它们仍归文档所有

对不归你控制的文件该不该调高 DamagedRowsBeforeError

不该,至少不该靠改 PDF,而且通常根本不该动。这个参数属于生成方,只有它知道自己的传输信道会不会丢位。一份声明了 DamagedRowsBeforeError 0 却真有损坏行的文件,是在告诉你归档副本已损坏,诚实的结果就是一条可记录的解码失败。合同或病历上的行被悄悄补上,可检测的缺陷就变成了一张看着没问题却缺了一行的图像,对日后依赖它的人是更坏的结局

对不可信归档,更稳妥的做法是保持严格解码,记下哪些对象号失败,把它们转给人工或另一个解码器。归档很大且来源众多时,把图像解码放到进程外跑,能限制恶意流对宿主应用的伤害;处理不可信 PDF 时隔离图像编解码器一文讲了这套部署,JBIG2 双色压缩指南则比较了同一批扫描归档里你会遇到的另一个双色滤镜

FAQ

HotPDF 支持 Group 4 图像里的 CCITT 非压缩模式吗

支持。HotPDF 能识别二维行里的 10 位扩展 0000001111 和一维行里的 12 位入口码 000000001111,解码字面码字,并在游程编码恢复时尊重退出标记位

为什么 DamagedRowsBeforeError 对我的 Group 4 传真没有效果

恢复需要 EOL 标记来重新同步。HotPDF 只在 K 为 0 或正数、且 EndOfLine 为真时应用 DamagedRowsBeforeError;Group 4 数据(K 为负)没有 EOL,任何损坏行都会终结解码

ExtractLoadedImage 对损坏的 CCITT 图像返回什么

当损伤超出 DamagedRowsBeforeError 允许的范围、或根本无法恢复时,THotPDF.ExtractLoadedImage 返回 nil。它从不返回残缺的位图,所以要判断 nil,并用 ExtractLoadedImageToStream 留存原始字节

HotPDF 用纯 Delphi 和 C++Builder 代码处理已加载 PDF 里的 CCITT、JBIG2、JPEG、JPEG 2000 和 Flate 图像,传真路径不依赖任何外部 DLL。详情与版本见 HotPDF Delphi PDF component 页面