技術文章

在 Delphi 中使用 HotPDF 將 PDF 頁面渲染為點陣圖

HotPDF 透過單一呼叫:RenderLoadedPageToBitmap(PageIndex, DPI),即可將已載入的 PDF 頁面渲染為 Delphi 的 TBitmap。該函式會直譯頁面的內容串流,並以您選擇的解析度傳回一個由呼叫者擁有的 24 位元 RGB 點陣圖,這正是縮圖列、預覽列印,或是 PDF 轉圖片匯出管線所需要的。本文將帶您了解這個 API,接著探討區分「可用渲染器」與「玩具」的關鍵:直接從內嵌的字型程式本身,而不是從外觀相似的系統字型來繪製文字

為什麼渲染 PDF 頁面比繪製圖片更難?

PDF 頁面不是一張圖片。它是一個程式:一連串建立路徑、選擇字型、設定色彩以及放置字形的運算子串流,並根據 ISO 32000-1 §8 中定義的圖形模型來執行。檔案中沒有任何東西說明任何一個像素長什麼樣子。要產生點陣圖,您必須執行該程式——維護當前變換矩陣 (current transformation matrix)、用於 q/Q 的圖形狀態堆疊、剪裁路徑、填滿與描邊色彩空間——並將結果光柵化。這就是為什麼「只是把第 3 頁當成圖片顯示出來」其實是一個內容串流直譯器,而不是檔案格式轉換

在 v2.253.0 引進的 HotPDF 渲染器,是建立在反映該模型的六個解耦單元之上:用於 PDF [a b c d e f] 變換代數的仿射矩陣 (affine-matrix) 核心、圖形狀態堆疊、色彩空間解析器(DeviceRGB、DeviceGray、DeviceCMYK、Indexed)、將 PDF 路徑運算子橋接至 GDI 的路徑建立器、讀取 /Widths 陣列以獲得正確前進量 (advances) 的字型度量層,以及分派運算子並驅動其他五個單元的直譯器。圖片 XObjects 會通過程式庫用於提取的同一套解碼堆疊,因此HotPDF 能夠為了提取而解碼的每一個圖片濾鏡——包含JPXDecode 壓縮的 JPEG 2000 圖片——也會出現在渲染的輸出中

將已載入的頁面渲染為 TBitmap

RenderLoadedPageToBitmap 接收一個以零起始的頁面索引以及一個 DPI 值,其中 72 DPI 會將一個 PDF 使用者空間單位對應到一個像素。在失敗時(索引超出範圍、缺少資源),它會傳回 nil 而不是引發例外,這樣檢視器就能跳過損壞的頁面並繼續執行。呼叫者擁有傳回的點陣圖,並必須負責釋放它

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('report.pdf') > 0 then
    begin
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);  // 第 1 頁,解析度 144 DPI
      if Bmp <> nil then
      try
        Image1.Picture.Assign(Bmp);
      finally
        Bmp.Free;  // 呼叫者擁有該點陣圖
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

DPI 引數為每個常見情境完成了縮放工作。縮圖列在 36 或 48 DPI 下渲染,會獲得小巧快速的點陣圖;螢幕預覽在 96 或 144 DPI 下,與典型的顯示器密度相符;在 300 DPI 下的匯出路徑則會產生列印品質的圖片。來自 /Rotate 條目的頁面旋轉,以及 /MediaBox 的原點翻轉(PDF 將原點放在左下角,而 GDI 在左上角),都在頁面至裝置矩陣內部處理完畢,因此一個在 72 DPI 下的美規信紙 (US Letter) 頁面,傳回時會剛好是 612×792 像素,而且方向正確

為什麼渲染出來的 PDF 縮圖會顯示錯誤的字形?

在渲染出來的 PDF 輸出中,出現錯誤或近似的字形,幾乎都意味著渲染器正在替換使用系統字型,而不是使用檔案中內嵌的字型。第一個版本的 HotPDF 渲染器正是這麼做的:它剝離 /BaseFont 的子集字首(將 ABCDEF+Arial 變成 Arial),向 GDI 要求該名稱的系統字型,然後用它來繪製文字。對於使用 Arial 或 Times New Roman 搭配標準編碼的文件來說,結果看起來很接近。但這終究是一個近似值,而且會在定義明確的情況下崩潰

內嵌子集字型是最糟的情況。一個子集字型可能只帶有該文件實際使用的四十個字形,而其字元代碼的指定順序是該檔案私有的——代碼 1 可能是「T」,代碼 2 是「h」,依此類推。系統字型對這個私有配置一無所知,因此文字要麼消失,要麼完全變成錯誤的字元。自訂編碼、符號字型、條碼字型,以及任何未安裝在渲染機器上的字體,都會以同樣的方式失敗。一個停留在「替換系統字型」階段的渲染器,會產生能認出是該頁面的縮圖——直到頁面使用了那些一開始就必須內嵌的字型為止

內嵌字形渲染:直接從字型程式本身進行繪製

HotPDF 在五個版本(v2.268.0 至 v2.272.0)的過程中,透過解析內嵌的字型程式並將其字形外框當作填滿的 GDI 向量路徑來重播,從而彌補了這個落差。渲染頁面中的文字現在來自與合規檢視器相同的輪廓資料,這表示子集字型、自訂編碼以及未安裝的字體都能以其精確的形狀進行渲染。這項支援是依據不同的字型風味 (flavour) 逐步建立的:

對於帶有內嵌 TrueType 程式 (FontFile2) 的 Type0/CIDFontType2 字型,渲染器會直接解析 glyfloca 表格:二次輪廓會被轉換為 GDI 能理解的三次貝茲曲線 (cubic Béziers);連續的非曲線上點之間的隱含曲線上點會被重建;複合字形會遞迴重播。Identity 與明確的串流 CIDToGIDMap 配置皆受到支援,而 CID 前進量會遵從 /W/DW 寬度條目,因此雙位元組的 Identity-H 文字能正確地步進

CFF 程式(FontFile3,無論是 CIDFontType0CType1C,或是 OpenType 包裝器)則獲得完整的 Type 2 字串 (charstring) 直譯器:直線、曲線、flex 系列、提示遮罩 (hint masks),以及具有正確子常式偏移量 (bias) 的區域/全域子常式呼叫。以 CID 為鍵值的 CFF 程式會透過字型的字元集來對應字元代碼,這對字形順序與 CID 順序不同的子集字型來說非常重要,且透過 FDArray/FDSelect 針對每個字形選擇 font-DICT 的設定也會被遵從。簡單(非 CID)的 TrueType 字型會透過內嵌字型自己的 cmap 表格及強健的子表格鏈來解析單一位元組代碼——首先是 Unicode 格式 4 與 12,接著是帶有 F000 私有使用區鏡像的符號子表格,然後是舊版的 Macintosh 格式——而簡單的 Type1 字型則透過 CFF 程式內建的編碼來解析

最後有兩項改良完成了整個拼圖。首先,簡單字型的 /Encoding 字典會根據 ISO 32000-1 §9.6.6 所規定的優先順序進行解析:/Differences 陣列會覆寫基礎編碼,基礎編碼則覆寫字型程式自己的對應表——這是 TeX 與源自 PostScript 的工具鏈所依賴的路徑,其中字形名稱會透過 Adobe Glyph List、CFF 字元集或 TrueType cmap 來解析。其次,Type3 字型,其字形本身就是小型的內容串流,會將字型矩陣、字型大小與文字矩陣組合後透過渲染器進行重播;字形空間的 /Widths 會依照 ISO 32000-1 §9.6.5 的要求透過 /FontMatrix 進行直譯,而宣告了 d1 邊界框的字形程序則會被剪裁至該框內,因此一個格式錯誤的條碼字形不會繪製到它的儲存格之外。當某個代碼無法被對應時——損壞的程式、未對應的字元——渲染器會針對該字形退回使用系統字型進行繪製,而不是放棄整個文字串 (text run)

如何讓重複的渲染變快?

HotPDF 提供的答案是一個最近最少使用 (MRU) 的頁面快取:RenderLoadedPageToBitmapCached 會保留最多 RenderCacheCapacity 個(預設為 8)以頁面索引和 DPI 為鍵值的已渲染頁面,且快取命中時會傳回一個由呼叫者擁有的全新副本,而完全無需接觸內容串流——這通常比重新直譯該頁面快上數千倍。這種模式完美契合檢視器的需求:使用者在兩頁之間翻動,或是一個調整大小事件以相同的 DPI 重新請求相同的頁面,每次都會命中快取

// 縮圖列:第一次通過時進行渲染,往回捲動時則命中快取
for I := 0 to ThumbCount - 1 do
begin
  Bmp := Pdf.RenderLoadedPageToBitmapCached(I, 48);
  if Bmp <> nil then
  try
    ThumbList.AddThumbnail(I, Bmp);
  finally
    Bmp.Free;
  end;
end;

// 在原地編輯已載入的頁面後:
Pdf.InvalidateRenderedPageCache;  // 下一次渲染就會反映出變更

在提高容量之前,請誠實面對記憶體開銷。一個 300 DPI 的美規信紙頁面為 2550×3300 像素,作為一個 24 位元的點陣圖大約是 25 MB,因此八個匯出解析度的快取頁面大約會佔用 200 MB。在縮圖的 DPI 下,同樣八個條目的開銷則遠低於 1 MB。請針對您實際快取的 DPI 來設定 RenderCacheCapacity 的大小,並在任何原地編輯之後呼叫 InvalidateRenderedPageCache——快取僅以頁面和 DPI 為鍵值,它無法察覺底層內容已經改變。載入新文件時會自動清除它

第二個快取在頁面快取的底層運作:解碼後的圖片 XObjects 會保留在一個由 ImageCacheMaxBytes(預設為 32 MB)設定位元組預算的儲存區中,並採用最近最少使用 (LRU) 淘汰機制。每個頁面上都重複出現的標誌或信紙抬頭圖片,在每次載入文件時只需解碼一次,而不是每次遇到 Do 運算子就解碼一次,這將使具有共用圖片頁面的渲染時間大約減半,並以同樣的幅度加快多頁 TIFF 的匯出速度。InvalidateRenderedPageCache 也會一併清除這個快取

還有哪些東西是近似渲染的

渲染器以常見的文件 PDF 子集為目標,因此了解其極限在哪裡是值得的。CalRGB、Lab 以及基於 ICC 的色彩空間是採用近似處理而非色彩管理——裝置色彩空間、Indexed 調色盤,以及取樣 Type 0 函式色彩查找都有被處理,但依賴 ICC 渲染意圖 (rendering intents) 的印前製作檔案將無法在色度上完全精準。著色圖樣 (sh) 以及超越簡單 alpha 的混合模式同樣不在處理範圍內,而 Form XObject 的遞迴則具有深度限制以防範循環。對於發票、報告、合約以及表單——由文字、路徑與圖片所組成的頁面——其輸出是忠實的;而對於充滿漸層與透明度群組的設計校樣,請將點陣圖視為預覽,而不是校樣

實際的解讀是:如果您的管線使用 HotPDF 產生文件,或消費典型的商業 PDF,RenderLoadedPageToBitmap 能以精確的內嵌字形形狀、正確的 CID 前進量,以及正確的頁面幾何,讓它們完美往返 (round-trip)。這些近似處理只存在於商業文件極少觸及的圖形模型角落

RenderLoadedPageToBitmap、其快取變體,以及此處描述的內嵌字形渲染管線,皆作為 Delphi 與 C++Builder 版 HotPDF Component 的一部分發布——這是一個不依賴外部 DLL 的原生 VCL 程式庫,在單一封裝中涵蓋了 PDF 建立、編輯、文字提取以及頁面渲染等功能