技術文章

JBIG2 隨機存取檔案:在 Delphi 中解碼

PDFlibPas 3.539.23 版解碼採用 ITU-T T.88 Annex D.2 隨機存取組織的獨立 JBIG2 檔案:所有區段標頭排在最前面,區段資料按相同順序跟在後面。PDFlibJBIG2.pas 裡的原生 Pascal 解碼器把標頭位移一路索引到強制的 end-of-file 標頭、檢查區段編號遞增、確認宣告的資料長度加起來剛好等於剩餘的位元組,然後按標頭順序解碼每個主體,不拷貝也不重排壓縮資料。這個版本之前,同一個檔案在標頭 flags 被讀到的瞬間,就換來一句平板的「random-access organisation is not supported」

隨機存取的 JBIG2 檔案很罕見,而這正是它們出現時特別傷的原因。它們出自封存管線與文件影像系統,這些系統希望讀取器在碰任何一個壓縮位元組之前,先看過每一個區段標頭,也就看到了每一個頁面與字典依賴。批次把掃描檔案庫轉成 PDF 的 Delphi 應用,通常是在幾百個循序檔案順利通過之後、工作中途撞上這麼一個檔案,而對格式良好的檔案當場罷工的解碼器,比渲染出垃圾的解碼器好不了多少。同一條版本線剛教會解碼器JBIG2 自訂 Huffman 表與標準前綴碼,隨機存取就是解碼器文件上剩的最後一個組織能力缺口

JBIG2 的隨機存取組織是什麼?

隨機存取組織是 T.88 Annex D 允許的三種區段佈局之一:循序(D.1)把標頭與資料交錯排列,隨機存取(D.2)把所有標頭放前面、所有資料放後面,內嵌(D.3)是無標頭形式,用在 PDF 這類外層容器裡。獨立的 .jb2 檔以八位元組識別碼 97 4A 42 32 0D 0A 1A 0A 開頭,跟著一個 flags 位元組,頁數已知時再加一個四位元組頁數。flags 位元組的 bit 0 選組織,1 是循序、0 是隨機存取;bit 1 設起表示頁數未知、四位元組計數不存在。PDFlibPas 在 checkHeader 與 setFileHeaderFlags 裡讀這些,保留的 bit 2 到 7 是容忍而不是拒絕

PDFlibPas 中的 JBIG2 檔案組織:setFileHeaderFlags 讀到的 flags 位元組選出標頭交錯的循序 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 串流,沒有檔案標頭、end-of-page 或 end-of-file 區段。當 decodeJBIG2 找不到八位元組識別碼,它假設的正是這種情況,並強制循序、單頁解碼。原生的 JBIG2 影像匯出走另一個方向,把 PDF 的區段包進一份獨立檔案,flags 位元組是 $03,循序、頁數未知,後面附加一個 end-of-file 標頭。所以隨機存取的工作只碰一條路徑:直接交給 TPLJBIG2Decoder 的獨立檔案,通常是在它們被轉換或重壓縮成 PDF 之前,而輸出側的活兒由 PDFlibPas 的 JBIG2 編碼器後端負責

為什麼隨機存取檔案不能照檔案順序讀?

因為除了 end-of-file 區段標頭本身,位元組流裡沒有任何東西標出標頭區塊在哪裡結束、資料區塊從哪裡開始。JBIG2 區段標頭是變長的:被參照區段數可以是三位元的短形式,或帶保留位元圖的長形式;被參照區段編號依區段自身編號佔一、二或四個位元組;頁面關聯欄位是一或四個位元組。天真的循序讀取器剖析第一個標頭、讀它的資料長度,然後把第二個標頭的前幾個位元組當成那個區段的資料。解碼器要到很久之後才會發覺自己出錯,這就是為什麼舊程式碼直接拒絕這種組織,連試都不試

PDFlibPas 怎麼為隨機存取的區段標頭建索引?

PDFlibPas 用一次預掃 IndexRandomHeaders 為隨機存取標頭建索引:剖析每個標頭、只記下它的位元組位移、停在第一個 end-of-file 標頭(區段型別 51)。每個標頭都被完整剖析後丟棄,所以索引是整數陣列而不是物件清單,預掃一路累加宣告的資料長度。掃描結束時,讀取器停在第一個區段資料的第一個位元組上,這個位置成為 NextBodyOffset

PDFlibPas 的 IndexRandomHeaders 預掃:每個區段標頭被剖析後丟棄、只保留位元組位移,區段編號必須嚴格遞增,0xFFFFFFFF 未知長度被拒,掃描停在型別 51 的 end-of-file 標頭,宣告長度必須與剩餘位元組完全相等
嚴格是刻意的:在標頭是資料唯一地圖的佈局裡,一個多餘位元組代表之後每個主體都可能位移,容忍它的解碼器分不出填補與錯位
// 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」失敗。這份嚴格是刻意的:在這種佈局裡,長度不匹配代表錯誤點之後的每個主體都位移了,而對一個多餘位元組聳聳肩的解碼器,無從判斷它是無害的填補,還是資料錯位的第一個症狀

最後一個 end-of-page 區段為什麼消失了?

因為解碼迴圈的第一版保留了循序版的終止測試 while not reader.isFinished,而在隨機存取佈局裡,資料流會在標頭索引用完之前先耗盡。end-of-page(型別 49)與 end-of-file 區段帶零位元組資料,它們通常是檔案裡最後的標頭。最後一個區域主體被消化完之後,讀取器正好停在緩衝區結尾,迴圈就退出了,那些零長度區段永遠等不到分派,頁面於是收不了尾。修法讓隨機存取迴圈改數標頭而不是數位元組。每次迭代把讀取器跳到下一個被索引的標頭、把 bitPointer 重設為 7(因為前一個主體可能結束在位元組中間)、重新剖析那個標頭,然後把 bytePointer 移到 NextBodyOffset 並越過主體前進。既有的區段處理器、被參照區段檢查與 Context 診斷原樣運行,錯誤訊息照舊回報標頭的原始位元組位移,不是主體的位置

PDFlibPas 的隨機存取解碼迴圈:每次迭代跳到目前標頭的 HeaderOffsets、把 bitPointer 重設為 7 以復位元組中途的尾巴、跳到 NextBodyOffset 取主體,並以標頭計數取代位元組計數,讓零長度的 end-of-page 區段在迴圈結束前得到分派
end-of-page 與 end-of-file 區段帶零位元組資料,資料流因此在標頭索引之前耗盡,只有以標頭計數的迴圈能讓這幾個最後的區段輪到上場
// TJBIG2StreamDecoder.readSegments, main loop
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 位元組的合成檔,建構在自訂表治具上,必須解出一列 7 x 1 的黑像素,頁數已知與移除計數欄位兩種情況都測;接著把那份檔案的每一個截斷前綴、一個重複的區段編號、一個多出的尾位元組與一個未知資料長度逐一餵給解碼器,每次都斷言 LoadFromByteArray 回傳 False、Width 與 Height 留在零。真實影像案例是一張 500 x 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 函式庫產品頁上有說明