技術文章

在 Delphi 中使用 HotPDF 將 JPEG 2000 影像加入 PDF

掃描的醫療切片、航拍測量圖磚、以完整動態範圍封存的電影影格——這些影像以 JPEG 2000 的形式出現,而且這樣出現是有原因的。這個格式每個通道保留 12 或 16 位元,用小波轉換壓縮而非 JPEG 的分塊 DCT,還能用同一條 codestream 把同一張圖編成無損或有損。當由這些來源構成的文件必須變成 PDF 時,影像就得走 PDF 規格專為這個編碼器保留的濾器

HotPDF 以一套靜態繫結 OpenJPEG 2.5.4 的 JPEG 2000 解碼引擎處理這條路徑,把 JP2 或 J2K 來源變成 HotPDF 能放上頁面的像素

PDF 中的 JPXDecode 濾器

ISO 32000-1 在 §7.4.9 定義了 JPXDecode 濾器。PDF 影像 XObject 在串流字典的 /Filter 項目裡聲明自己的壓縮方式,JPXDecode 這個值表示串流資料是一條 JPEG 2000 codestream,而不是 /DCTDecode 所承載的基線 JPEG。有了這個濾器,PDF 才裝得下高位元深度的小波壓縮影像資料;它同時接受編碼器的無損與有損模式,因為模式是 codestream 本身的屬性,與外層包裝無關

最後這一點值得記住。JPEG 2000 是單一演算法加上無損特例,不是兩種分開的格式。可逆的 5/3 小波能精確重建原始取樣;不可逆的 9/7 小波用這份精確換取更小的檔案。解碼器在讀取時對兩者一視同仁,所以 HotPDF 只需要一條解碼路徑,就能接下 JPXDecode 串流丟來的任何東西

解碼器對像素做了什麼

常見情況下,PDF 影像 XObject 期待每個元件 8 位元的 DeviceGray 或 DeviceRGB。JPEG 2000 動輒超過這個範圍,其元件模型也比打包式點陣圖更一般化,所以在資料能當普通影像用之前,解碼器有三件事要做

第一,高位元深度元件被重新取樣到 8 位元。12 位元或 16 位元的取樣會被縮放到 0 到 255 的範圍,結果就是一份普通的 8 位元點陣。帶符號元件會先平移到無號範圍。這個細節很重要,因為它本身就是有損的:16 位元灰階掃描在變成 8 位元 PDF 影像的那一刻就失去深色調層次——對螢幕顯示與列印輸出是合理的取捨,對再封存則不是

第二,YCbCr(編碼器稱之為 SYCC)色彩空間會被轉成 RGB。JPEG 2000 為了壓縮效率常把顏色存在亮度—色度空間,與基線 JPEG 是同一個思路,解碼器套用標準逆轉換,讓頁面拿到真正的 RGB

第三,經過次取樣的元件會以最近鄰複製升採樣。色度通道常以一半解析度儲存,解碼器按各元件自己的尺寸與取樣因子讀取,然後複製取樣點把每個通道補到完整影像尺寸,再交錯排列。最近鄰讓這一步保持便宜;要補的色度本來就是低頻資訊,可見的代價很小

Delphi 中 JPEG 2000 解碼路徑的管線圖:帶 12 或 16 位元取樣、SYCC 色彩與次取樣色度的 JP2 或 J2K 來源,被重取樣到 8 位元、轉成 RGB、升採樣色度,成為交錯排列、可直接交給 DeviceGray 或 DeviceRGB XObject 的點陣
無論 JPXDecode 串流裝了什麼,解碼器最終都落在適合 DeviceGray 或 DeviceRGB XObject 的普通 8 位元交錯點陣上

JP2 box 與裸 J2K codestream

JPEG 2000 檔案有兩種形態,HotPDF 從開頭幾個位元組、而不是從副檔名判斷讀到的是哪一種。JP2 檔是以 box 為結構的容器:以 12 位元組的簽章 box 00 00 00 0C 6A 50 20 20 開場,把 codestream 與描述色彩空間、解析度、中繼資料的各個 box 包在一起。裸 J2K codestream 完全沒有容器,以 SOC 標記 FF 4F FF 51 開頭。解碼器讀這些前導位元組,認出簽章,為每種情況選擇對應的 OpenJPEG 編解碼器

兩種形態都支援,因為兩種都真實存在。需要附帶中繼資料的擷取設備與檔案庫輸出 JP2;想要最小負載的工具輸出裸 codestream。格式類型建模成列舉 TJpeg2000FileType,成員有 jtInvalid、jtJP2、jtJ2K 與 jtJPT。JPT 成員指的是 JPIP 串流變體;位元組簽章偵測會解析它解得動的兩種形態(JP2 與 J2K),其餘一律回報 jtInvalid,讓不支援的輸入乾淨地失敗,而不是產出垃圾

HotPDF 從前導位元組偵測 JPEG 2000 檔案類型的圖解:以簽章 00 00 00 0C 6A 50 20 20 開場的 box 結構 JP2 容器,與以 FF 4F FF 51 SOC 標記開場的裸 J2K codestream,一起進入首位元組檢查,回傳 jtJP2、jtJ2K,不支援的輸入則回傳 jtInvalid
HotPDF 只靠最前面幾個位元組辨識容器,並把不支援的簽章對應到 jtInvalid,讓解碼乾淨地失敗
uses
  HPDFJpeg2000;

var
  Decoder: THPDFJpeg2000Decoder;
  Pixels: TJpeg2000ByteArray;
begin
  Decoder := THPDFJpeg2000Decoder.Create;
  try
    if Decoder.LoadFromStream(Input) then          // JP2 或 J2K,自動偵測
      if Decoder.GetImageData(Pixels) then
        // Pixels 為 8 位元交錯排列,寬度是 ColorComponents 個通道,
        // 列主序、由上而下:可直接交給 DeviceGray/DeviceRGB XObject。
        ProcessRaster(Decoder.Width, Decoder.Height,
                      Decoder.ColorComponents, Pixels);
  finally
    Decoder.Free;
  end;
end;

編碼端的無損與有損

解碼器不必被告知模式就能讀兩種。只有反過來、要產出 JPEG 2000 檔案時,選擇才變成參數——HotPDF 也做得到,靠的是 TJpeg2000Bitmap 類別,一個以 JP2 載入與儲存點陣資料的 TBitmap 後代。兩個屬性決定輸出:LosslessCompression 是布林值,為真時選用可逆小波;CompressionQuality 是 TJpeg2000QualityRange,1 到 100 的整數,1 小而醜,100 大而忠實。預設值放在具名常數裡:Jpeg2000DefaultLosslessCompression 為 False,Jpeg2000DefaultLossyQuality 為 80

這個決定取決於內容。無損適合母片、醫療或法律掃描,任何日後可能重新編碼、不能累積世代損失的東西。品質 80 的有損適合走向螢幕或列印的圖片,小波的優雅退化換來明顯更小的檔案,而且不會留下讀者抓得到的瑕疵。有一個 CMYK 注意事項要提醒:這個點陣圖類別提供 SetCMYK,把四通道資料標成 CMYK 而非 RGBA,對保留分色的列印管線來說這件事有影響

uses
  HPDFJpeg2000;

var
  Bmp: TJpeg2000Bitmap;
begin
  Bmp := TJpeg2000Bitmap.Create;
  try
    Bmp.LoadFromStream(Source);              // 解碼既有的 JP2/J2K
    Bmp.LosslessCompression := True;         // 可逆 5/3 小波
    // 或者,要更小的有損檔:
    // Bmp.LosslessCompression := False;
    // Bmp.CompressionQuality := 80;         // 與預設值相同
    Bmp.SaveToStream(Output);                // 一律寫出 JP2 檔
  finally
    Bmp.Free;
  end;
end;

為什麼沒有載入即解碼的濾器管線

有一個架構事實決定了您怎麼使用這一切,而且很容易假設成相反。HotPDF 沒有通用的「載入即解碼」影像濾器。開啟一份已經內含 JPXDecode 影像的 PDF 時,引擎不解碼那條串流。它讓 JPEG 2000 位元組原樣保留,頁面複製或文件合併因此能把影像原封不動、逐位元組地帶過去。解碼器只有一個入口,而且在創建端:以檔案為單位的 AddImage,按副檔名分派,處理 .jp2、.j2k、.jpt 與 .jpc 來源

這條分界是正確的設計,不是限制。載入時解碼內嵌 JPX 串流、儲存時再編碼回去,會把一張無損封存的影像變成有損版本,並讓每一次合併都膨脹——而您只是想把圖從一份 PDF 搬到另一份。讓串流原樣通過是無損操作,而且快。解碼被推遲到真正需要的那一刻:您把磁碟上的 JPEG 2000 檔交給引擎,要求把這張圖點陣化、放到新頁面上。到了這一步,檔案非變成像素不可,解碼器於是出動

雙車道對比圖:HotPDF 的載入路徑中,內嵌 JPXDecode 位元組在頁面複製或合併時原封不動、逐位元組通過;創建路徑中 AddImage 先經 OpenJPEG 解碼 JP2 檔一次,ShowImage 再放置並重新內嵌點陣
在文件之間搬運既有影像是逐位元組複製;解碼工作只在 AddImage 把 JP2 或 J2K 檔變成像素時發生

註冊支援與放置影像

JPEG 2000 圖片註冊是選配,藏在 HPDF_REGISTER_JPEG2000_PICTURE 編譯開關後面,預設關閉。原因是一場真實的衝突,不是過度謹慎:把 jp2、j2k、jpc 格式全域註冊給 TPicture,可能干擾 ReportBuilder 的 TppDBImage 所依賴的 BLOB 格式偵測。沒有那層整合時就定義這個開關,檔案格式會完成註冊,TPicture 認得它們;不定義它,AddImage 的副檔名分派照樣直接解碼 JPEG 2000 檔案,因為那條路根本不經過 TPicture

理解這一點之後,放置 JPEG 2000 圖片與放置其他 HotPDF 影像一樣,是同樣的三呼叫節奏。把 .jp2 路徑和一個壓縮類型交給 AddImage,說明圖片在輸出中該以什麼形式儲存,然後用 ShowImage 把傳回的影像索引擺到頁面上

var
  Pdf: THotPDF;
  ImgIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginDoc;
    Pdf.AddPage;
    // .jp2 來源先經 OpenJPEG 後端解碼,接著
    // 以您在此指定的壓縮方式重新內嵌。
    ImgIndex := Pdf.AddImage('Scan_16bit.jp2', icJpeg);
    // x、y、寬、高以 point 為單位;最後一個 0 是旋轉角度。
    Pdf.CurrentPage.ShowImage(ImgIndex, 72, 72, 400, 300, 0);
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

您傳給 AddImage 的壓縮方式控制的是解碼後圖片的重新儲存形式,而不是它當初怎麼被讀進來。JPEG 2000 檔案解碼成點陣圖後,可以再以 DCTDecode JPEG、Flate 點陣或其他支援的濾器輸出,哪種適合文件就用哪種。無論如何,JP2 或 J2K 的解碼都先發生,所以同一個呼叫既收得下小波壓縮來源,也能按管線其餘部分期待的任意形式內嵌

影像與字型如何進入產出文件的整體圖像,請參閱報表輸出字型與影像的筆記。當您組裝的文件重用既有 PDF 的內容時,這裡描述的原樣直通行為,可以搭配物件串流與增量更新中的合併與修訂機制。JPEG 2000 解碼引擎隨 Delphi 與 C++Builder 適用的 HotPDF Delphi Component 一併出貨,與本部落格其他文章涵蓋的影像、字型與文件 API 同在一套元件裡