技術文章

在 Delphi 中從已載入的 PDF 提取圖片:HotPDF

您的磁碟上有一份 PDF,這是客戶從一疊發票中掃描來的,而您的工作是將頁面圖片提取為點陣圖 (bitmap),以便進行光學字元辨識 (OCR) 處理。您載入了檔案,找到了圖片 XObjects,然後發現了一個沒有人會警告您的細節:這些串流中的位元組並不是像素 (pixels)。它們是 JPEG 編碼串流 (codestream)、經過小波壓縮 (wavelet-compressed) 的 JPEG 2000 區塊、Group 4 傳真資料流,或者是隱藏在調色盤與 Flate 濾鏡後的索引點陣圖。圖片物件知道自身的寬度與高度,但實際的樣本卻被封裝在產生器所選擇的任何濾鏡中。要取得可用的 TBitmap,就意味著必須解開該濾鏡,而 PDF 大約提供了八種不同的位元組封裝方式

這正是 HotPDF(Delphi 與 C++Builder 原生的 VCL PDF 元件)中 ExtractLoadedImage 填補的空缺。它會列舉您所載入文件中的圖片 XObjects,報告每一個圖片是什麼類型,並將它能夠解碼的圖片轉回 24 位元點陣圖。有趣的部分不在於 API 介面本身(那只有三個方法),而在於為什麼一開始就必須存在一條獨立的解碼路徑,以及它能(或不能)將什麼東西轉回像素

為什麼已載入的圖片沒有被預先解碼

HotPDF 的載入器是圍繞著「直接通過 (pass-through) 保持保真度」而建立的。當您呼叫 LoadFromFile 時,圖片串流會完全按照它們在來源檔案中出現的方式保留:原始濾鏡、原始壓縮位元組、原始字典。這是刻意為之的。載入文件通常的目的是為了複製頁面、合併檔案、蓋印章 (stamp)、重新設定權限,然後將它們寫回,而對於所有這些操作,最廉價且最安全的做法就是不觸碰每個圖片串流。如果在載入時就將每個圖片解碼為點陣圖,會將記憶體與 CPU 消耗在大多數呼叫者根本不需要的工作上,而在儲存時重新編碼則會降低本應原樣複製的圖片品質

結果是,已載入的物件圖 (object graph) 不攜帶任何像素。一個 /Filter/DCTDecode 的圖片 XObject 存放著 JPEG 位元組;HotPDF 從未對其執行過 JPEG 解碼器,因為在複製並重寫的路徑中沒有任何環節需要這麼做。因此,當您實際需要像素時,提取 API 必須親自從頭開始,針對該特定圖片碰巧使用的濾鏡進行解碼。這也是編碼端 (encode-side) 的編解碼器 (codec) 獨立於載入器的相同原因:關於在 Delphi 中將 JPEG 2000 圖片新增至 PDF 的文章描述了 JPX 引擎如何插入建立端 (creation side),而該引擎在提取 API 需要它之前,根本就未連線至讀取路徑

三個方法的 API

介面很小。GetLoadedImageCount 傳回載入的文件包含多少個圖片 XObjects。GetLoadedImageInfo 透過索引為其中一個圖片填入描述元紀錄 (descriptor record)。ExtractLoadedImage 傳回解碼後的點陣圖,若無法解碼該圖片則傳回 nil。列舉是基於索引且對特定載入而言是穩定的:在內部,它會走訪間接物件表 (indirect-object table) 並收集每個 /Subtype 解析為 /Image 的串流,因此您傳遞給 GetLoadedImageInfo 的索引,與傳遞給 ExtractLoadedImage 的索引是相同的

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

這裡有兩個必須注意的合約細節。首先,傳回的 TBitmap 由您負責釋放;文件本身不會快取或擁有它。其次,請在呼叫前檢查 Decodable,並在呼叫後檢查結果是否為 nil。該方法在遇到不支援的濾鏡時不會引發例外,它會傳回 nil,而在批次迴圈中一個安靜的 nil,正是那種會在沒有人注意到的情況下吞掉千頁工作其中一頁的陷阱

解碼前讀取描述元

THPDFLoadedImageInfo 在不承諾進行完整解碼的情況下,告訴您圖片是什麼。它的欄位直接來自圖片字典:以樣本 (samples) 計算的 WidthHeight、描述解碼後如何解釋的 BitsPerComponentColorComponentsColorSpace(1 代表灰階,3 代表 RGB,4 代表 CMYK)、作為命名壓縮的 Filter、用於網板遮罩 (stencil mask) 的 IsImageMask、底層間接物件的 ObjectNumber,以及 Decodable

最後一個旗標是最誠實的。只有當執行中的組建 (build) 實際上能將這個特定的濾鏡與色彩空間組合轉換為點陣圖時,Decodable 才會是 True。它編碼了真實的支援矩陣,而不是一種期望:若某個圖片的 Filter 目前的組建無法理解,它就會回報 Decodable = False,而您可以依據這個條件來建立分支,以記錄日誌、略過,或者退回使用您自己的方式去提取原始串流。請把它當作前置條件,而不是一個提示

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

一個實作細節經常咬到手動建立描述元紀錄的人。THPDFLoadedImageInfo 包含兩個 AnsiString 欄位:FilterColorSpace。這些是具有參考計數的管理型別 (managed types),因此在這裡習慣性地使用 FillChar(Info, SizeOf(Info), 0) 來清空紀錄是錯誤的:它會在沒有遞減參考的情況下覆寫字串參考,從而導致記憶體洩漏或損壞。HotPDF 之所以逐個欄位初始化該紀錄,正是基於這個原因,如果您在自己的程式碼中複製這種模式,請採取相同的做法

一個分派器,八條濾鏡路徑

這個功能之所以花了一系列的版本發布,而不是一次搞定,是因為 PDF 並沒有單一的圖片格式。它擁有濾鏡,且 ISO 32000-1 §8.9.5 允許圖片 XObject 在 /Filter 中指定其中的任何一個,而樣本的解讀則由 /ColorSpace/BitsPerComponent 以及一個選用的 /Decode 陣列獨立控制。ExtractLoadedImage 會讀取濾鏡名稱,並路由至各情況的專屬解碼器。從 v2.229 擴展至 v2.231 所建立的支援集合,現在涵蓋了八條不同的路徑

  • 原始點陣圖 (FlateDecode、LZWDecode 或無濾鏡)(8 位元 DeviceRGB 或 DeviceGray)。位元組會解壓縮為打包的點陣圖,而唯一的轉換是色頻交換 (channel swap)(下文會說明)
  • DCTDecode (JPEG)。編碼串流會交給 VCL 的 TJPEGImage 處理,由它解析幾何與色彩,其結果會指派到一個 24 位元的點陣圖中
  • JPXDecode (JPEG 2000)。透過 OpenJPEG 後端解碼(與 JPEG 2000 文章中描述的是同一個引擎),並將高位元深度 (high-bit-depth) 的元件重新取樣降至 8 位元
  • 索引色彩 (Indexed)。從 [/Indexed base hival lookup] 陣列讀取調色盤,並將每個樣本透過查表法擴充為全彩
  • DeviceCMYK。四色頻樣本透過標準的「白底油墨 (ink-on-white)」公式轉換為 RGB
  • 低於 8 位元的 DeviceGray 與 Indexed(每元件 1、2 或 4 位元),逐個樣本解開打包並縮放至 0–255 範圍
  • CCITTFaxDecode,即 Group 3 與 Group 4 傳真濾鏡,由專屬的 T.4/T.6 後端進行解碼
  • JBIG2Decode,高壓縮比的雙色濾鏡,透過已註冊的 JBIG2 後端解碼(這在原生的 JBIG2 壓縮文章中已從編碼端涵蓋)

所有路徑最終都會落腳在同一個地方:一個 24 位元的 BGR 點陣圖,因為這正是 VCL TBitmap 原生儲存的格式,也是每個下游消費者所期望的

會默默改變像素的轉換

這些路徑中有兩個涉及很容易出錯的轉換,即使您從不親自接觸解碼器,也值得了解。第一個是色彩順序的交換。PDF 的 DeviceRGB 點陣圖以紅-綠-藍 (red-green-blue) 的順序儲存樣本,頂部行優先。而 VCL 24 位元掃描線則以藍-綠-紅 (blue-green-red) 的順序儲存它們。因此,解碼純 RGB 圖片並非單純的 memcpy;進入掃描線的途中,每個像素的第一個和第三個位元組必須互換。如果弄反了,紅色與藍色就會交換位置,這在灰階測試圖片上看起來沒問題,但在彩色圖片上卻會錯得離譜。不過,行順序 (Row order) 倒是直接對應的:PDF 由上而下的點陣圖與 VCL 的 ScanLine[0] 作為頂部視覺行完全吻合,因此不需要垂直翻轉

第二個是 CMYK。PDF DeviceCMYK 圖片帶有四種油墨,且轉換為 RGB 是一個逐色頻的計算,而不是查表:每個輸出色頻為 (255 - ink) * (255 - K) / 255。這是一種裝置近似值,而不是透過 ICC 設定檔的色彩管理轉換,因此結果對於顯示與重新光柵化來說夠正確了,但如果您需要列印級別的準確色彩,這並不是正確的途徑。如果您的工作流程需要保真度,請將提取的點陣圖視為預覽,並保留原始 CMYK 串流供色彩管理的管線使用

Indexed 路徑隱藏了它自己的一個解析陷阱。/Indexed 色彩空間中的調色盤可以儲存為文字字串 (literal string) 或十六進位字串 (hexadecimal string),而 HotPDF 將十六進位字串的值儲存為十六進位文字,而不是解碼後的位元組。因此,當調色盤是十六進位字串時,尋找表必須先經過十六進位到位元組 (hex-to-bytes) 的解碼;如果是文字字串,則已經是原始位元組。如果遺漏了這個分支,四色索引圖片輸出就會變成亂碼,因為每一個調色盤條目都在錯誤的位元組邊界上被讀取

濾鏡鏈:最後一個濾鏡才是圖片真正的濾鏡

單一的 /Filter 名稱是簡單的情況。PDF 也允許一個濾鏡鏈 (chain),這表示串流已經依序通過幾個濾鏡,並按照在 /Filter 陣列(例如 [/ASCII85Decode /FlateDecode][/ASCIIHexDecode /DCTDecode])中列出的順序進行處理(ISO 32000-1 §7.4)。其語意很精確:在編碼時濾鏡由左至右套用,因此在解碼時您必須由右至左解開它們,而陣列中的最後一個濾鏡,才是真正定義該圖片格式的濾鏡。前面所有的濾鏡,只不過是包裝在它外面的傳輸編碼而已

提取器會透過剝離 (peeling) 的方式來處理這點。在任何圖片解碼器執行之前,濾鏡鏈中除了最後一個濾鏡之外的每一個濾鏡都會先被套用,以產生最終濾鏡預期的輸入,然後才會對該最後一個濾鏡進行分派 (dispatch)。因此,[/ASCII85Decode /DCTDecode] 會先將串流解除 ASCII85 編碼,然後將結果路由到 JPEG 路徑;而包裝在原始點陣圖外面的 [/FlateDecode] 會先解壓縮,然後執行點陣圖路徑。這就是讓八個解碼器保持簡單的原因。它們都不需要知道 ASCII85 或 hex 傳輸包裝的事,因為當解碼器看到這些位元組時,包裝早已消失。這也代表著如果鏈中最後一個濾鏡不被支援,它仍會在分派步驟中乾淨地失敗,而不是在半途出錯

提取停止的地方,以及然後該怎麼做

誠實面對這些界限。一個最終濾鏡超出了支援集合的圖片會傳回 nil,對於一個色彩空間無法被組建解讀的圖片也是一樣。柔和遮罩 (soft masks) 與 Alpha 不會被重建到點陣圖中;您會得到基礎圖片,而不是合成後的結果。來自 JPEG 2000 高於 8 的位元深度會被重新取樣降級,這是有意為之的失真操作;如果您是要重新封存 (archiving) 而不是顯示,那這會是錯誤的做法。而且,圖片遮罩 (image mask)——一個沒有自身色彩的一位元網板 (stencil)——雖然描述元有記載它,但它與繪畫性質的圖片是不同的東西;如果您期待解出一張照片而對其進行解碼,您將會大吃一驚

當提取不足以滿足需求時,原始串流(包含濾鏡在內的所有內容)仍然完好地保存在載入的物件圖中,您可以逐個位元組將其拉出,交給您自己專業的編解碼器。這是直接通過 (pass-through) 的設計刻意保留的退路:原始位元組永遠不會被丟棄,所以最壞的情況,也只是您必須親自解碼,而不是資料憑空消失。不過,對於大多數實際工作而言,這八個受支援的濾鏡已經涵蓋了掃描器、辦公室套件以及報表引擎實際會產出的格式,而只要透過帶有 Decodable 防護的 GetLoadedImageCount 迴圈,短短幾行程式碼就能將載入的 PDF 轉回一個裝滿點陣圖的資料夾

此處描述的已載入圖片提取 API,連同整套的解碼濾鏡,均隨附於 Delphi 與 C++Builder 版的 HotPDF Component