技術文章

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

手寫的 JBIG2 讀取器遇到第一份不尋常的檔案就會犯這個錯。它解析檔案標頭、解析第一個段標頭、讀取四個位元組的資料長度,然後把那麼多位元組當成段主體。在多數 .jb2 檔案上這樣完全正確。但在旗標位元組 bit 0 清零的檔案上,那些「主體」位元組其實是第二、第三、第四個段標頭,算術解碼器把它們變成一張噪點點陣圖,或一路衝出緩衝區末端。檔案沒有損壞——它用的是 ITU-T T.88 附錄 D.2 的隨機存取組織:所有段標頭排在前面,所有段資料依同樣順序跟在後面

隨機存取檔案很少見。它們出自檔案典藏與文件影像系統——這類系統希望讀取端在碰任何壓縮位元組之前,先看齊每一頁與每一個字典依賴。轉檔作業若把這種來源的檔案和普通掃描件混在一起,一份隨機存取檔案就可能藏在一批全是順序式的檔案中間。讀它們的解碼器,和處理 JBIG2 自訂 Huffman 表與標準前綴碼的是同一套原生 Pascal 解碼器,編碼側一概不變;變的只是找到段的順序

一個旗標位元組背後的三種檔案布局

PDF Library for Delphi 透過 PDFlibJBIG2 單元裡的 TPLJBIG2Decoder 讀取獨立的 JBIG2 檔案,解碼器自己從檔案標頭判斷布局。T.88 附錄 D 允許同樣的段排成三種方式:順序式(D.1)把每個標頭直接放在其資料前面;隨機存取式(D.2)把所有標頭排在最前、所有資料跟在其後;內嵌式(D.3)是其他容器裡用的無標頭形式。獨立檔案以八位元組識別碼 97 4A 42 32 0D 0A 1A 0A 開頭,接著一個旗標位元組,再一個四個位元組的頁數——除非旗標說頁數未知

旗標位元組的 bit 0 決定組織:1 是順序式,0 是隨機存取。bit 1 設位代表頁數未知、四個位元組的計數缺席,於是第一個段標頭從位元組 9 開始,而不是位元組 13。bit 2 到 7 保留;解碼器接受設了這些位的檔案而不拒收,因為一個亂設的保留位,說明不了段本身讀不讀得了

PDFlibPas 中的 JBIG2 檔案組織示意圖:setFileHeaderFlags 讀取的旗標位元組決定標頭交錯的順序式 D.1、所有標頭在資料塊之前的隨機存取式 D.2,或無標頭的內嵌式 D.3——JBIG2Decode 串流使用的正是 D.3,字典放在 JBIG2Globals
三種布局承載同樣的段,但只有隨機存取能讓讀取端在碰壓縮位元組之前看齊每一頁與字典依賴——檔案典藏管線要的就是這個

PDF 本身從不攜帶隨機存取布局。JBIG2Decode 圖像串流(ISO 32000-1 §7.4.7 所述)只以內嵌形式裝頁面段,共用的符號字典移進獨立的 JBIG2Globals 串流,沒有檔案標頭、end-of-page 或 end-of-file 段。解碼器找不到八位元組識別碼時,就把輸入當成那種串流:順序式、單頁。反過來,PDF Library for Delphi 把 JBIG2Decode 圖像匯出成原生 .jb2 檔案時,寫出旗標位元組 $03(順序式、頁數未知),並附加一個 end-of-file 段標頭。所以隨機存取永遠只從外部而來——作為一份直接交給 TPLJBIG2Decoder 的獨立檔案,通常正要被轉換或為 PDF 重新壓縮,配合 PDFlibPas 的 JBIG2 編碼器後端

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

因為除了 end-of-file 段標頭(類型 51)本身,位元組流裡沒有任何東西標示標頭塊在哪裡結束、資料塊在哪裡開始。JBIG2 段標頭沒有固定大小:被參照段數可以是三位元的短形式,或長形式再跟一張保留位圖;每個被參照段號依目前段自身編號的大小占一、二或四個位元組;頁面關聯欄位一個位元組或四個。讀取端必須解析標頭的每個欄位,才知道下一個標頭從哪裡開始

按檔案順序行走的順序式讀取器,把第一個標頭讀對,之後的一切讀錯。更糟的是它可能一時半刻還不自覺:吃進標頭位元組的通用區域解碼器產出的是像素而不是錯誤,直到某個後段的長度或引用檢查終於絆倒。我們的看法是:不先索引標頭的解碼器,應該直接拒收隨機存取檔案。明確的拒收,一批檔案只賠一份;一張看起來合理卻錯誤的點陣圖,會進檔案庫、並留在那裡

在解碼第一個主體之前,先索引每一個標頭

旗標位元組說是隨機存取時,PDF Library for Delphi 在解碼任何東西之前先對標頭塊掃一遍。每個標頭完整解析後即丟棄,唯一留下的是它在檔案裡的位元組偏移量。壓縮資料絕不會被複製進重排過的第二緩衝——在大型典藏掃描上這件事很要緊,因為資料塊可能占掉檔案的大半。掃描在第一個 end-of-file 標頭處停下,此時讀取位置正好落在第一個段資料的第一個位元組上

隨機存取檔案的冗餘比順序式少,所以預掃描是嚴格的。以下情況直接拒收:

  • 標頭被緩衝區末端截斷,回報為 JBIG2 truncated random-access header at byte N,附上該標頭開始的偏移量
  • 段號未嚴格遞增(以無號值比較),因為兩個標頭聲稱同一編號,會讓後段區域的被參照清單變得含糊
  • 資料長度的最高位被設置——包括 0xFFFFFFFF 的「未知長度」記號;這種布局裡沒有別的辦法找到下一個主體的起點,解碼器立即拒收,而不是向前掃描找結束記號
  • end-of-file 標頭聲明任何資料,或乾脆沒有出現(JBIG2 random-access file is missing its end-of-file header)
  • 聲明的長度加起來比剩餘位元組多,或少
PDFlibPas 的 IndexRandomHeaders 預掃描示意圖:每個段標頭解析後即丟棄、只保留其位元組偏移量,段號必須嚴格遞增,0xFFFFFFFF 未知長度遭拒,掃描停在類型 51 的 end-of-file 標頭,聲明長度必須與剩餘位元組恰好相等
嚴格是刻意的:在標頭是資料唯一地圖的布局裡,一個多餘位元組代表之後每個主體都可能位移,容得下它的解碼器分不清填充與錯位

最後一條規則是大家最容易被絆倒的。最終主體之後多出一個位元組,就以 JBIG2 trailing random-access data 失敗。與順序式路徑相比顯得苛刻——順序式解碼在 end-of-file 段停下、之後的一切直接忽略。不對稱有它的理由。順序式布局裡每個標頭緊挨著自己的資料,末尾的雜物位移不了任何東西;隨機存取布局裡標頭是資料塊唯一的地圖,長度對不上時,解碼器分不清無害填充,和更早處丟失或多出、之後每個主體都跟著位移的那一個位元組

為什麼按位元組數跑的迴圈會漏掉最後一個 end-of-page 段?

「還有位元組就繼續」的迴圈在隨機存取檔案上收得太早,因為資料比標頭索引先耗盡。end-of-page(類型 49)和 end-of-file 段不帶任何資料位元組,通常排在最後。最後一個區域主體消化完時,讀取位置正好在緩衝區末端;按位元組數跑的迴圈在此退出,零長度的 end-of-page 段永遠等不到分派,頁面就此未完成

PDFlibPas 的隨機存取解碼迴圈示意圖:每輪跳到目前標頭的 HeaderOffsets、把 bitPointer 重置為 7 以抵銷跨位元組殘尾、跳到 NextBodyOffset 取主體,並以標頭計數而非位元組計數,讓零長度的 end-of-page 段在迴圈結束前獲得分派
end-of-page 與 end-of-file 段不帶資料位元組,資料流因此比標頭索引先耗盡,只有以標頭計數的迴圈能讓這幾個收尾段輪到出場

所以對隨機存取檔案,解碼器數的是標頭,不是位元組。每一步跳回下一個已索引的標頭、對齊位元組邊界(算術編碼的主體可能停在位元組中間)、重新解析標頭,然後向前跳到下一個主體的起點、按聲明長度越過它。解碼器提前收工的主體被跳到其聲明末端;讀過了頭的主體遭拒。從那裡起,段走與順序式檔案相同的處理器和引用檢查。解碼主體時拋出的錯誤帶著標頭的原始位置,形式為 JBIG2 segment N (type T) at byte B: ...,LastError 裡的偏移量因此指向十六進位編輯器裡找得到的東西

在 Delphi 裡載入隨機存取的 .jb2 檔案

應用程式碼不隨組織而改變。TPLJBIG2Decoder.LoadFromByteArray 偵測檔案標頭與布局,任何被拒的輸入回傳 False 並把原因放進 LastError,解碼後的頁面經 Width、Height 和 GetScanline 暴露——每像素一個位元組,黑為 1

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;

想知道某份檔案用的是哪種布局——為了記錄日誌,或為了在轉檔前把語料分類——自己讀那個旗標位元組就好。函式庫並不暴露它偵測到的組織,而九個位元組就夠了。下面的輔助函式是加進你自己程式碼的東西;它只是把 T.88 的標頭規則複述一遍:

type
  TJb2Layout = (jlNoFileHeader, jlSequential, jlRandomAccess);

// 你自己的輔助函式,不屬於 PDFlibJBIG2:按 T.88 檔案標頭為緩衝分類
function DetectJb2Layout(const Data: TJBIG2ByteArray;
  out PageCountKnown: Boolean): TJb2Layout;
const
  Signature: array[0..7] of Byte = ($97, $4A, $42, $32, $0D, $0A, $1A, $0A);
var
  I: Integer;
begin
  Result := jlNoFileHeader;          // 例如從 PDF 裡取出的內嵌串流
  PageCountKnown := False;
  if Length(Data) < 9 then
    Exit;
  for I := 0 to 7 do
    if Data[I] <> Signature[I] then
      Exit;
  PageCountKnown := (Data[8] and 2) = 0;   // bit 1 設位:沒有四個位元組的頁數
  if (Data[8] and 1) = 0 then
    Result := jlRandomAccess
  else
    Result := jlSequential;
end;

批次作業:填充緩衝與被拒檔案

尾隨資料規則正是批次程式碼容易出錯的地方;當一份別處開得了的檔案被這樣拒收時,先看緩衝再看檔案。從資料庫 BLOB 欄位取出的 .jb2、固定大小的網路讀取、帶對齊填充的容器格式,都可能在真正的結尾之後送來幾個零位元組。順序式檔案對此聳聳肩;同樣填充的隨機存取檔案會以 JBIG2 trailing random-access data 失敗,被剪短幾個位元組的緩衝也一樣——以截斷名義失敗。請傳剛好是檔案本身的位元組、按來源真實長度取大小,永遠不要按區塊大小或容量

被拒的檔案也不留殘跡。LoadFromByteArray 在開始解碼前重置 Width、Height 和掃描列,失敗的呼叫回報零維度而不是上一份檔案的頁面,同一個解碼器實例可以直接接下一份檔案:

procedure DecodeBatch(const Files, Log, Quarantine: TStrings);
var
  Decoder: TPLJBIG2Decoder;
  I: Integer;
begin
  Decoder := TPLJBIG2Decoder.Create;
  try
    for I := 0 to Files.Count - 1 do
      if Decoder.LoadFromByteArray(ReadJb2(Files[I])) then
        Log.Add(Format('%s: %d x %d', [Files[I], Decoder.Width, Decoder.Height]))
      else
      begin
        // 這裡 Width 和 Height 是 0;上一份檔案什麼都不會留下
        Log.Add(Files[I] + ': ' + Decoder.LastError);
        if Pos('trailing random-access data', Decoder.LastError) > 0 then
          Quarantine.Add(Files[I]);   // 怪罪檔案之前,先查位元組是怎麼被存放的
      end;
  finally
    Decoder.Free;
  end;
end;

在自己的管線裡驗證這些行為,不需要稀有的樣本檔。把一份順序式 .jb2 重組成隨機存取布局,保留每個標頭和每個壓縮位元組,把 end-of-page 與 end-of-file 標頭挪到標頭塊末端,解碼出的像素必須與順序式原始檔完全一致——帶頁數欄位與不帶都要測。然後餵它每種截斷前綴、重複的段號、一個多餘的尾隨位元組和一份 0xFFFFFFFF 長度;每一個都應回傳 False 且 Width、Height 為零

解碼器回傳什麼,又期待什麼

這裡的隨機存取是檔案布局的性質。無論檔案用哪種組織,TPLJBIG2Decoder 都回傳第一頁的點陣圖,Width 和 Height 描述的也是那一頁。每個段都必須聲明資料長度;T.88 允許立即式通用區域使用 0xFFFFFFFF 記號,解碼器在兩種布局下都以 unknown or oversized segment length is not supported 拒收該記號。自訂 Huffman 前綴長度與表條目數的上限,與順序式檔案同一套

解碼 JBIG2 只是更長的圖像路徑中的一站,同一路徑還涵蓋 PDF 圖像擷取、重新壓縮與 JBIG2 編碼;PDF Library for Delphi 產品頁涵蓋管線的其餘部分與支援的圖像格式