技術文章

Delphi 的 CCITTFaxDecode:未壓縮模式與受損列

HotPDF 這個原生 Delphi PDF 元件解碼 CCITTFaxDecode 影像,完整支援 T.4/T.6 未壓縮模式延伸,以及由 DamagedRowsBeforeError 驅動的受損列復原。未壓縮模式只會由以 111 結尾的延伸碼進入,受損列也只在設了 EndOfLine 的 Group 3 資料上修復。其餘情況一律乾淨地失敗,而不是產出一頁歪斜的內容

傳真編碼影像是 PDF 影像解碼的長尾。您遇到的大多數 CCITT 串流出自掃描器驅動或文件擷取管線,那些東西只會輸出純 Group 4,只處理常見情況的解碼器能通過您丟給它的每一項測試。然後某個十五年 fax server 的封存檔進來了,一頁從第 400 列往下算繪成斜向雜訊,您才發現 ITU-T 建議書裡沒人實作的兩個部分,恰好就是這份檔案用到的那兩個。本文照 HotPDF 的處理方式走一遍這兩個部分,也走一遍解碼器看起來正確、其實出錯的那些地方

哪些 CCITTFaxDecode 參數真的會改變位元流?

DecodeParms 字典裡有四個條目會改變解碼器框取位元流的方式:K、EndOfLine、EncodedByteAlign 與 DamagedRowsBeforeError。ISO 32000-1 §7.4.6(Table 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     exit, then resume runs with color T
  00000001T    W, exit
  000000001T   W W, exit
  0000000001T  W W W, exit
  00000000001T W W W W, exit
  more than ten zeros: malformed }

標籤位元是微妙之處。行程編碼是有狀態的:一維列在白行程與黑行程之間交替,二維列則追蹤目前變化元素 a0 的顏色。退出碼之後,解碼器必須以標籤指定的顏色繼續,那個顏色可能對、也可能不對得上最後一個字面像素。當標籤與解碼器正在追蹤的顏色不同時,HotPDF 解碼器會在目前位置記下一筆顏色變更,讓下一個行程從正確的顏色起步。無視標籤,這一列剩下的部分就會整段顏色顛倒,而在 2D 模式下,後面每一列都會透過參考列繼承這個損害

DamagedRowsBeforeError 能復原什麼、不能復原什麼?

DamagedRowsBeforeError 讓 HotPDF 換掉損壞的列然後繼續,但只在 K 為 0 或正值、且 EndOfLine 為 true 時生效。這兩個條件不是隨便定的。復原需要在位元開始不知所云之後,有辦法找到下一列從哪裡開始,而 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 解碼,或反過來。一維行程解碼器在列中間撞見意外的 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 為 true 時套用 DamagedRowsBeforeError;Group 4 資料(K 為負)沒有 EOL,所以任何受損列都會終止解碼

受損的 CCITT 影像,ExtractLoadedImage 會回傳什麼?

當損害超出 DamagedRowsBeforeError 的容忍、或根本無法復原時,THotPDF.ExtractLoadedImage 回傳 nil。它絕不回傳殘缺的點陣圖,所以請測 nil,並用 ExtractLoadedImageToStream 保留原始位元組

HotPDF 用純 Delphi 與 C++Builder 程式碼處理已載入 PDF 裡的 CCITT、JBIG2、JPEG、JPEG 2000 與 Flate 影像,傳真路徑不需要任何外部 DLL。細節與版本資訊見 HotPDF Delphi PDF component 產品頁