技術文章

從 Delphi 在 PDF 中內嵌 AVIF、HEIF 與 JPEG XL 影像

PDF Library for Delphi 透過 AddModernImageFromFile 及其串流與字串變體,接受 AVIF、HEIF 與 JPEG XL 影像作為輸入,在寫入 PDF 影像物件的過程中保留 alpha、內嵌的 ICC 描述檔與 16 位元色版。格式偵測是靠一次有邊界限制的魔數讀取來完成,解碼則透過一個可替換的後端執行,因此對一個實際上並非這幾種格式的檔案,不會呼叫任何外部程式

這些格式是透過手機進入文件工作流程的。iOS 多年來預設就產生 HEIC,Android 裝置產生 AVIF,而一位在現場拍攝損壞零件照片的技術人員送出的影像,2015 年打造的 PDF 報表產生器根本完全無法開啟。一般的後備路徑(透過平台點陣圖解碼)能穩定得到 8 位元色彩,卻會在過程中失去 alpha 與色彩描述檔

現代影像路徑保留了哪些點陣圖轉換會失去的東西?

三樣東西,每一樣都對應著一種依賴它的工作流程。Alpha 會保留下來,這對疊在頁面內容上的商標與去背產品圖很重要。ICC 描述檔會保留下來,這對任何要印刷或需要色彩比對的東西很重要。16 位元色版會保留下來,這對醫療與科學影像很重要,因為 8 位元量化會摧毀影像拍攝時原本要捕捉的那些漸層細節

把影像透過平台點陣圖傳遞,這三樣東西會在同一步驟中全部流失,而且是無聲無息地流失:產生出來的 PDF 看起來大致正確,直到印刷廠問企業標準紅為什麼不對了,才有人注意到。現代影像呼叫中選項值為 8 的旗標,正是保留 alpha、ICC 與 16 位元色版的組合,也是這些呼叫的預設值

PDF Library for Delphi 圖解對比:平台點陣圖轉換會丟掉 alpha、ICC 色彩描述檔與 16 位元色版,現代影像路徑則把三者完整帶入 PDF 影像物件
一般的點陣圖解碼會默默壓平透明度並量化色版,頁面根本還沒渲染就發生了

把影像加到頁面上

這個呼叫會回傳一個影像識別碼,接著可以選定並繪製,或一步到位地繪製並釋放:

uses
  PDFlibrary, PDFlibModernImage;

var
  Lib: TPDFlib;
  ImageID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.SetPageSize('A4');
    Lib.NewPage;

    // Options = 8 保留 alpha、ICC 與 16 位元色版
    ImageID := Lib.AddModernImageFromFile('site-photo.heic', 8);
    if ImageID > 0 then
      Lib.DrawImageAndRelease(ImageID, 40, 40, 515, 340)
    else
      Lib.DrawText(40, 40, 'image could not be decoded');

    Lib.SaveToFile('inspection-report.pdf');
  finally
    Lib.Free;
  end;
end;

偵測發生在解碼之前,而且刻意設計得很狹窄。函式庫讀取一段有邊界限制的標頭,辨識用來識別 AVIF 與 HEIF 的 ISO 基礎媒體檔案格式品牌標記,也辨識 JPEG XL 的原始與容器兩種簽章形式,接著還原呼叫端的串流位置。未知或偽裝的輸入永遠不會傳到外部編解碼器,這也防止了一個改了副檔名的執行檔被當成圖片交給解碼器

解碼實際上發生在哪裡?

現代影像格式是龐大且複雜的編解碼器,把其中一個塞進 PDF 函式庫本身會是個奇怪的設計選擇。預設後端會在行程內動態載入一個可部署的 MagickWand 模組,並依一套文件化的順序尋找它:你明確設定的檔案或目錄、環境變數、執行檔所在目錄,以及系統搜尋路徑

有界限的魔術數字偵測關卡圖解:先確認 AVIF、HEIF 與 JPEG XL,再從明確指定路徑、環境變數、執行檔目錄或已註冊的 Delphi 回呼中挑選解碼後端
偵測僅讀取有限長度的檔頭,因此改名的執行檔不會抵達編解碼器

已經自帶解碼器、或完全不得載入外部模組的應用程式,則可以改為註冊自己的回呼函式。合約很小:讀取輸入串流、把 PNG 寫入輸出串流、遵守要求的方向設定:

function MyDecoder(InStream, OutPNG: TStream;
  ImageFormat: TPDFlibModernImageFormat;
  ApplyOrientation: Boolean): Boolean;
begin
  // 用你自己的編解碼器解碼 InStream,把 PNG 位元組寫進 OutPNG
  Result := DecodeWithBundledCodec(InStream, OutPNG,
    ImageFormat, ApplyOrientation);
end;

begin
  RegisterModernImageDecoderBackend(MyDecoder);
  // ... 新增影像 ...
  ClearModernImageDecoderBackend;    // 回到預設後端
end;

部署方面有一項便利設計與一項刻意的克制。若編解碼器目錄底下含有一個 modules\coders 子目錄,函式庫會在主應用程式尚未自行設定的情況下,補上這種佈局所需的編解碼器環境變數。已有自己一套執行環境部署策略的應用程式,會保留自己的做法

為什麼中間要經過 PNG?

透過記憶體中的 PNG 而非原始像素緩衝來橋接,看起來像是多出來的一步,實際上卻是成本最低的正確做法。PNG 能表達所有必須保留下來的東西:alpha、色彩類型、位元深度與內嵌的 ICC 描述檔,而函式庫已經有一條從 PNG 進到 PDF 影像物件、搭配正確篩選器與色彩空間的成熟且經過充分測試的路徑。重用它,代表現代格式能繼承多年累積下來的正確性成果,而不必另外打造一套平行實作

這座橋接完全在記憶體中進行,因此不會建立任何暫存檔,也不需要在崩潰時做清理。有一個小狀況需要明確處理:某些轉換在改變格式時會遺失 ICC 描述檔。後端因此會在格式切換之前先擷取來源描述檔,以 Flate 壓縮它,建構一個帶有重新計算 CRC 的合法 iCCP 區塊,並移除任何會與其衝突的 sRGB 區塊。在測試中,一份解碼過的 AVIF 保留了 16 位元 RGBA 搭配 16 位元 alpha,從產生的 PDF 中抽出的描述檔,與來源描述檔在 60,960 位元組上逐位元組完全相符

PDF Library for Delphi 記憶體內 PNG 橋接圖解:在切換格式前擷取來源 ICC 描述檔,重建為壓縮的 iCCP chunk,移除衝突的 sRGB chunk,並重用經過驗證的 PNG 轉 PDF 內嵌路徑
在格式切換前先快照 ICC 設定檔,讓內嵌副本與來源逐位元組一致

投入正式環境前的實務提醒

在啟動時就確認可用性,而不是等到第一張照片進來才發現問題。ModernImageCodecAvailable 會回報是否有可用的後端,SetModernImageCodecLibrary 則能在你的部署把編解碼器放在非標準位置時,指向一個明確的檔案或目錄:

Lib.SetModernImageCodecLibrary('C:\MyApp\codecs');
if Lib.ModernImageCodecAvailable = 0 then
  Log('modern image input unavailable - HEIC and AVIF will be refused');

留意成品的檔案大小。一張帶有內嵌描述檔的 16 位元 RGBA 影像,是個很大的 PDF 影像物件,一份帶有四十張這種影像的報表會很龐大。當文件的目的地是螢幕檢視而非印刷時,內嵌前先降採樣是正確的取捨,一般的檔案大小調整手段說明於 PDF 檔案大小最佳化 一文

最後,請刻意決定色彩政策。保留來源描述檔,對封存與印刷工作是正確做法;轉換到文件通用的色彩空間,則在一組混雜的照片必須看起來一致時才正確,轉換方式說明於 把文件重新著色到另一個色彩空間 一文。若你需要確認檔案裡實際落地的是什麼,文字、影像與字型擷取 一文所述的檢視路徑,能回報一份文件帶有的影像物件

現代影像輸入、色彩管理與影像最佳化,都是同一套適用 Delphi、C++Builder 與 Free Pascal 函式庫的一部分;完整功能清單列於 PDF Library for Delphi 頁面