技术文章

JBIG2 随机访问文件:在 Delphi 中解码

PDFlibPas 3.539.23 版开始解码采用 ITU-T T.88 附录 D.2 随机访问组织的独立 JBIG2 文件:这种布局里每个段头都排在前面,段数据按同样顺序跟在后面。PDFlibJBIG2.pas 里的原生 Pascal 解码器把段头偏移一路索引到强制的文件结束头,检查段号严格递增、声明的数据长度加起来恰好等于剩下的字节数,然后按头顺序解码每个段体,全程不复制、不重排压缩数据。在这个版本之前,同一个文件在读到头标志的那一刻就会抛出一个干巴巴的「random-access organisation is not supported」错误

随机访问 JBIG2 文件很少见,而这恰恰是它们真出现时最伤的地方。它们出自归档管线和文档影像系统——这些系统想让阅读器在碰任何压缩字节之前先看到每个段头,也就是每一页和每个字典依赖。批量把扫描档案转成 PDF 的 Delphi 应用通常在任务中间撞上它,此前几百个顺序文件都顺利通过,而一个在格式良好的文件上戛然而止的解码器,比一个渲染出乱码的解码器好不了多少。同一个发布线刚教会解码器JBIG2 自定义 Huffman 表与规范前缀码,随机访问就成了解码器文档化能力清单里最后一个组织方式缺口

什么是 JBIG2 的随机访问组织?

随机访问组织是 T.88 附录 D 允许的三种段布局方式之一:顺序式(D.1)把每个头与它的数据交错排列,随机访问式(D.2)把所有头放在最前、所有数据放在其后,嵌入式(D.3)是无头形式,用在 PDF 这类其他容器内部。独立 .jb2 文件以八字节标识 97 4A 42 32 0D 0A 1A 0A 开头,随后是一个标志字节,页数已知时再跟一个四字节页数。标志字节的第 0 位选择组织方式,1 表示顺序、0 表示随机访问;第 1 位置位表示页数未知、四字节计数缺席。PDFlibPas 在 checkHeader 和 setFileHeaderFlags 里读这些字段,第 2 到 7 位是保留位,容忍而不拒绝

PDFlibPas 中的 JBIG2 文件组织:setFileHeaderFlags 读取的标志字节选择头数据交错的顺序式 D.1、所有头都在数据块之前的随机访问式 D.2,或无头的嵌入式 D.3——JBIG2Decode 流采用后者,字典放在 JBIG2Globals 里
三种布局携带同样的段,但只有随机访问能让阅读器在碰任何压缩字节之前看到每一页和每个字典依赖,归档管线要的正是这一点
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = 随机访问(D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = 省略页数
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // PDF 流:无文件头,嵌入式组织,单页
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF 本身从不携带这种布局。ISO 32000-1 §7.4.7 描述的 JBIG2Decode 图像流只装嵌入式组织的页面段,共享符号字典移进单独的 JBIG2Globals 流,没有文件头、页结束或文件结束段。decodeJBIG2 找不到那八字节标识时,就假定正是这种情况,强制走顺序、单页解码。原生 JBIG2 图像导出则反过来,把 PDF 段包进一个标志字节为 $03(顺序、页数未知)的独立文件,末尾再附一个文件结束头。所以随机访问的工作只碰一条路径:直接交给 TPLJBIG2Decoder 的独立文件,通常是它们被转换或为 PDF 重新压缩之前——输出侧的活由 PDFlibPas 的 JBIG2 编码器后端承担

为什么随机访问文件不能按文件顺序读?

因为字节流里没有任何东西标记头块在哪里结束、数据块从哪里开始——唯一的界标就是文件结束段头本身。JBIG2 段头是变长的:被引用段计数可以是三位短形式,也可以是带保留位图的长形式;被引用段号按段自身号的大小占一、二或四个字节;页关联字段是一或四个字节。天真的顺序读者解析第一个头、读出它的数据长度,然后把第二个头的头几个字节当成那个段的数据。要过很久解码器才能意识到自己已经错了——这正是旧代码干脆拒绝这种组织、不去尝试的原因

PDFlibPas 怎样索引随机访问段头?

PDFlibPas 用一次预扫描 IndexRandomHeaders 索引随机访问头:解析每个头、只记录它的字节偏移、停在第一个文件结束头(段类型 51)上。每个头都被完整解析然后丢弃,所以索引是一个整数数组而不是对象列表,预扫描沿途累加声明的数据长度。扫描结束时,读指针正好落在第一个段的第一个数据字节上,这个位置成为 NextBodyOffset

PDFlibPas 的 IndexRandomHeaders 预扫描:每个段头都被解析后丢弃,只保留字节偏移,段号必须严格递增,0xFFFFFFFF 未知长度被拒绝,扫描停在类型 51 的文件结束头上,声明长度必须与剩余字节精确相等
严格是刻意的:在头是数据唯一地图的布局里,一个多余字节就意味着后面每个段体都可能错位,容忍它的解码器没法区分无害填充与错位数据
// IndexRandomHeaders,TJBIG2StreamDecoder.readSegments 的局部过程
while not reader.isFinished do
begin
  Offset := reader.bytePointer;
  Header := TSegmentHeader.Create;
  try
    readSegmentHeader(Header);
    if reader.BufferOverrun then
      raise EJBIG2DecodeError.CreateFmt(
        'JBIG2 truncated random-access header at byte %d', [Offset]);
    if (HeaderCount > 0) and
       (Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
      raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
    PreviousNumber := Header.getSegmentNumber;
    Count := Header.getSegmentDataLength;
    if Count < 0 then
      raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
    Inc(TotalLength, Count);                   // Int64 累加器
    HeaderOffsets[HeaderCount] := Offset;      // 按块扩容
    Inc(HeaderCount);
    if Header.getSegmentType = JBIG2_END_OF_FILE then
    begin
      if Count <> 0 then
        raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
      FoundEnd := True;
      Break;
    end;
  finally
    Header.Free;
  end;
end;
if not FoundEnd then
  raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;

那个循环里的每一项检查都源于随机访问文件比顺序文件更少的冗余。段号必须严格递增,且按无符号值比较,因为两个头声称同一个号会让后面区域的被引用列表指向哪个段体变得含糊。数据长度字段由 handleSegmentDataLength 读取,它把任何最高位置位的值——包括 0xFFFFFFFF 这个「未知长度」标记——映射为 -1;在随机访问布局里没有别的办法找到下一个段体的起点,所以 PDFlibPas 立即拒绝这种长度,而不是去扫描结束标记。总数必须在两个方向上都与剩余字节精确匹配,最后一个段体之后多出一个字节也会以「trailing random-access data」失败。这种严格是刻意的:在这个布局里长度不匹配意味着错误点之后的每个段体都错位了,而对一个多余字节耸耸肩的解码器无从知道它是无害的填充还是数据错位的第一个症状

为什么最后一个页结束段不见了?

因为解码循环的第一版保留了顺序式的终止判断 while not reader.isFinished,而在随机访问布局里数据流比头索引先耗尽。页结束(类型 49)和文件结束段不带任何数据字节,它们通常是文件里最后的头。最后一个区域段体被消费之后,读指针正好停在缓冲区末尾,循环退出,那些零长度段永远不会被分发,页面就停在未完成状态。修法让随机访问循环数头而不是数字节。每次迭代把读指针跳到下一个已索引的头,把 bitPointer 重置为 7(前一个段体可能结束在字节中间),重新解析那个头,然后把 bytePointer 挪到 NextBodyOffset 并越过段体。既有的段处理器、被引用段检查和 Context 诊断原样运行,错误消息仍报告头的原始字节偏移而不是段体位置

PDFlibPas 的随机访问解码循环:每次迭代寻址到当前头的 HeaderOffsets,把 bitPointer 重置为 7 以撤销字节中部的尾巴,跳到 NextBodyOffset 取段体,并从数字节改为数头,让零长度的页结束段在循环结束前得到分发
页结束与文件结束段不带数据字节,数据流比头索引先耗尽,只有数头的循环才能让这些收尾段轮到出场
// TJBIG2StreamDecoder.readSegments,主循环
if randomAccessOrganisation then
  IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
      ((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
  if randomAccessOrganisation then
  begin
    reader.bytePointer := HeaderOffsets[HeaderIndex];
    reader.bitPointer := 7;                    // 在半个字节之后重新对齐
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // 用于错误上下文
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // 跳到这个段的数据
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // …分发给既有的段处理器,然后定位到 DataEnd
end;

随机访问校验实际证明了什么?

校验证明的是:重排后的字节解码出的像素与顺序式原件相同,畸形的随机访问输入会干净地失败;它不证明对来自任意编码器的随机访问文件有覆盖。共享的 Pascal 回归用一个 235 字节的合成文件验证,它构建在自定义表 fixture 上、必须解出一行 7×1 的黑色像素,页数已知和去掉计数字段两种形态都要通过;随后把该文件的每个截断前缀、一个重复段号、一个多余尾字节和一个未知数据长度喂给解码器,每次都断言 LoadFromByteArray 返回 False 且 Width、Height 保持零。真实图像用例是一张 500×473 的自定义表 refinement 图像,它的段被重排成随机访问布局、每个原始头和压缩字节都保留;其 SHA-256 与经过评审的顺序式基线完全一致。那个文件是组织变换产出的衍生品,不是野外找到的天然随机访问文档,而这样的天然样本并不可得。各套件通过情况:Delphi Win32 1,598 个测试,Delphi Win64 图像套件 42 个,FPC Win32 48 个,FPC Win64 46 个,另有三个既有的顺序式像素用例

加载随机访问 .jb2 文件及其边界

应用代码不用改:TPLJBIG2Decoder.LoadFromByteArray 自己探测文件头和组织方式,任何被拒绝的输入返回 False、原因放在 LastError,并通过 Width、Height 和每像素一字节的 GetScanline 暴露解码出的页面

uses
  SysUtils, Classes, PDFlibJBIG2;

function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
  FS: TFileStream;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, FS.Size);
    if Length(Result) > 0 then
      FS.ReadBuffer(Result[0], Length(Result));
  finally
    FS.Free;
  end;
end;

function CountBlackPixels(const FileName: string): Integer;
var
  Decoder: TPLJBIG2Decoder;
  Row: TJBIG2ByteArray;
  X, Y: Integer;
begin
  Result := 0;
  Decoder := TPLJBIG2Decoder.Create;
  try
    // 顺序与随机访问的独立文件走同一个调用
    if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
      raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
    for Y := 0 to Decoder.Height - 1 do
      if Decoder.GetScanline(Y, Row) then
        for X := 0 to Decoder.Width - 1 do
          if Row[X] = 1 then
            Inc(Result);
  finally
    Decoder.Free;
  end;
end;

边界值得说清楚。随机访问支持是文件组织层面的功能,不是随机页面 API:TPLJBIG2Decoder 仍只返回第一页的位图,没有从 40 页文件里挑第 7 页或懒解码某页的调用。未知数据长度的段在随机访问文件中被拒绝,自定义 Huffman 前缀长度与表项数的既有上限不变。这些边界足够窄,Delphi 应用可以按 LastError 把被拒的情形路由到别处,而图像管线其余部分——从 PDF 图像抽取到 JBIG2 编码——在 PDFlibPas Delphi PDF library 产品页上有覆盖