技術文章

PDFium Library Config:Brotli 什麼時候會悄悄換掉 Skia

在 Delphi 版 PDFium Component 裡,打開 TPdfLibraryConfiguration 的 BrotliEnabled 或 IsolatePerDocument,以前會把隨附的 Skia 建置悄悄換成 AGG 渲染器,而且不報任何錯,因為這兩個選項都會把 FPDF_LIBRARY_CONFIG 抬到 PDFium 會照字面解讀 m_RendererType 的版本。v3.123.0 起預設渲染器維持 DLL 自己的預設,v3.125.0 起,DLL 兌現不了的 Skia 或 Fontations 要求會擲出可捕捉的 EPdfError,不再把行程殺掉

這兩個 bug 都不會自己出聲。第一個給您的頁面看起來一切正常,只是換了個光柵化器在渲染,反鋸齒與文字邊緣跟您出貨測試過的那份建置略有出入。第二個倒是有出聲,而且很大聲——從原生初始化內部直接把宿主行程帶走。兩者源自同一個地方:一個帶版本號的 C 結構,欄位要在版本號說了算之後才算數,而它的零值不是「未設定」,是真實的選擇

FPDF_LIBRARY_CONFIG 怎麼決定 PDFium 用哪個渲染器?

FPDF_InitLibraryWithConfig 只有在結構的 Version 欄位為 4 以上時才會看 m_RendererType,而且從該版本起照寫入的值原樣使用。版本不到 4,PDFium 無視這個欄位,直接用建置預設:以 PDF_USE_SKIA 編譯的建置用 Skia,其他一律 AGG

之後每個欄位都走同一套模式。這個結構一次長一種能力,而每種能力都伴隨一個新的版本號出現。PDFium Component 在 LoadLibrary 裡按您的 TPdfLibraryConfiguration 搭出原生結構,版本只抬到您設定的選項所需要的高度

結構版本新增欄位寫入時機
2m_pIsolate、m_v8EmbedderSlot一律寫入;V8Isolate、V8EmbedderSlot
3m_pPlatformV8Platform 非 nil
4m_RendererTypeRenderer 非 prpDefault
5m_FontLibraryTypeFontBackend 非 pfbpDefault
6m_BrotliEnabledBrotliEnabled = True
7m_IsolatePerDocumentIsolatePerDocument = True

陷阱就在最後兩列。版本是累積的:版本 6 的結構同時也是版本 4 與版本 5 的結構,所以哪怕您只想要 Brotli,PDFium 照樣會讀 m_RendererType 與 m_FontLibraryType。那一刻這兩個欄位裡躺著什麼,渲染器與字型後端就是什麼,不管您有沒有打算選

PDFium Component 的 FPDF_LIBRARY_CONFIG 版本階梯,從版本 2 到版本 7,標出哪個 TPdfLibraryConfiguration 選項帶來 m_RendererType、m_FontLibraryType、m_BrotliEnabled 與 m_IsolatePerDocument,以及版本累積之下歸零的渲染器欄位為何在任何建置上都算一次明確的 AGG 選擇、而非未設定值
每個選項都會抬高結構版本,而更早的欄位全部繼續生效,m_RendererType 裡的零於是以明確 AGG 請求的身分送進 PDFium

為什麼打開 Brotli 會把渲染器換成 AGG?

v3.123.0 之前,PDFium Component 對 prpDefault 一律往 m_RendererType 裡寫 FPDF_RENDERERTYPE_AGG,於是任何把結構推到版本 6 或 7 的設定,都會在 Skia 建置上強制 AGG。隨元件出貨的 pdfium.dll 與 pdfium.v8.dll 執行期都是 Skia 建置,所以中槍的正是預設部署,不是什麼冷門組合

這個映射剛寫下去的時候看起來無害。版本 2 或 3 時這個欄位根本沒人讀,prpDefault 確實就代表「DLL 做什麼就什麼」。等 BrotliEnabled(版本 6)或 IsolatePerDocument(版本 7)加入戰局,同一段程式碼就把「沒意見」變成了明確的 AGG 請求。什麼都沒失敗:PDFium 正常初始化、每一頁都渲染、也不回傳任何錯誤碼,因為在它看來,呼叫端要的就是 AGG,拿到的也是 AGG

像素雜湊能讓這次調包現形,螢幕截圖做不到。同一份範例文件的第一頁在三種設定下渲染,結果是:

  • 預設設定:雜湊 502D77C3711B4ACF
  • BrotliEnabled = True 而 Renderer 留在 prpDefault:雜湊 F75B5EB4728ADE87
  • 明確指定 prpAgg:雜湊 F75B5EB4728ADE87,與 Brotli 那一輪完全相同

v3.123.0 的修正是公開函式 PdfNativeRendererType,把 TPdfRendererPreference 解析成寫進 m_RendererType 的那個值。prpAgg 與 prpSkia 一對一映射;prpDefault 現在在載入的 DLL 有匯出 FPDF_RenderPageSkia 時解析成 Skia,否則 AGG。這個匯出與 Skia 預設本身在同一個 PDF_USE_SKIA 條件下編譯,因此成了您從 DLL 外部唯一觀察得到的建置性質。修正之後,Brotli 設定產生的雜湊與預設設定相同

PDFium Component 的像素雜湊對照:預設 Skia 渲染的雜湊 502D77C3711B4ACF,v3.123.0 之前開 BrotliEnabled 的設定與明確 prpAgg 一輪同得雜湊 F75B5EB4728ADE87,以及修正後的包裝層透過 FPDF_RenderPageSkia 匯出把 prpDefault 解析回原本的 Skia 雜湊
像素雜湊抓得到截圖藏住的差異:以前打開 Brotli 會讓每一頁都改用 AGG 渲染,修正後的預設則與沒動過的設定一致

字型後端從來沒鬧過同樣的問題。m_FontLibraryType 從版本 5 起被讀取,而它的零值 FPDF_FONTBACKENDTYPE_FREETYPE 恰好也是 PDFium 在欄位完全沒被讀時的預設。所以對 pfbpDefault 寫入 FreeType,就能分毫不差地重現原生預設。零值不見得錯,只是永遠不會自動變對

用 v3.123.0 以上,您自然而然會寫的啟動程式碼現在說到做到:

uses
  PDFium;

procedure ConfigurePdfiumAtStartup;
var
  Config: TPdfLibraryConfiguration;
begin
  // 必須在任何程式碼載入原生函式庫之前執行
  Config := TPdfLibraryConfiguration.Default;
  Config.BrotliEnabled := True;   // 把 FPDF_LIBRARY_CONFIG 抬到版本 6
  // Renderer 維持 prpDefault:有匯出 FPDF_RenderPageSkia 的建置解析成 Skia,
  // 純 AGG 建置則解析成 AGG
  SetLength(Config.UserFontPaths, 1);
  Config.UserFontPaths[0] := 'C:\ProgramData\MyApp\Fonts';
  ConfigurePdfLibrary(Config);
end;

請記住,BrotliEnabled 只有在 DLL 本身以 PDF_ENABLE_BROTLI 建置時,才能讓 PDF 2.0 的 /BrotliDecode 串流變得可解碼。這個旗標只是個請求,在不支援 Brotli 的建置上毫無作用。TPdfLibraryConfiguration.Hardened 與 Default 唯一的差別是 AllowMachineTime 為 False,擋下文件 JavaScript 讀取真實時鐘;拿它當伺服器端處理不可信任檔案的起點,是合理的

指定一個 DLL 裡根本沒有的後端,會發生什麼事?

對建置裡不存在的渲染器或字型後端,PDFium 不會回傳錯誤:FPDF_InitLibraryWithConfig 會觸發一個原生 CHECK 失敗,在 Windows 上以中斷點例外的形式浮現,而呼叫周圍若沒有結構化例外處理器,行程就此終止。標頭檔自己也這麼講,警告不支援的值「一樣會立即崩潰失敗」

具體的兩種情況:純 AGG 建置收到 FPDF_RENDERERTYPE_SKIA,以及沒有 Fontations 的建置收到 FPDF_FONTBACKENDTYPE_FONTATIONS。隨附的 Skia 執行期屬於第二種:渲染走 Skia,字型卻用 FreeType。對它同時指定 prpSkia 與 pfbpFontations,Delphi 端就冒出 External exception 80000003。就算偵錯器或例外處理器碰巧接住了,局面照樣救不回來:

  • PDFium 停在初始化到一半的狀態
  • 整個行程層級的設定已經鎖定,ConfigurePdfLibrary 會拒絕修正後的設定
  • 同一個行程裡換一份設定重試,已經沒有可能

這剛好是 Brotli bug 的相反版本。那邊是欄位躺著一個沒人選過的值,PDFium 默默收下;這邊是欄位躺著呼叫端深思熟慮選的值,PDFium 卻完全不給商量。兩者都是包裝層必須在原生呼叫之前解決的問題,因為呼叫之後,已經沒有東西留給您接了

PDFium Component 如何預先檢查 Skia 與 Fontations

v3.125.0 起,LoadLibrary 會在綁定 DLL 匯出之後、呼叫 FPDF_InitLibraryWithConfig 之前驗證設定,把不支援的渲染器或字型後端轉成 EPdfError,訊息裡點名出問題的設定與可用的替代選項。DLL 會被卸載、設定也解除鎖定,呼叫端可以換別的設定再載入一次

判斷本身住在純函式 PdfLibraryConfigurationSupportError 裡:傳入設定加上兩個描述建置能力的布林值,組合安全就回傳空字串。因為它不碰任何原生狀態,您可以在自己的測試裡用任意能力組合呼叫它。而在 LoadLibrary 內部,這兩個布林值來自不同種類的證據,值得信任的程度也不同:

  • Skia 以 FPDF_RenderPageSkia 匯出是否存在來偵測,與 PdfNativeRendererType 用的是同一個訊號。這個匯出與 Skia 渲染器在同一個條件下編譯,所以檢查是精確的
  • Fontations 沒有自己的匯出。它留下的唯一痕跡是被拉進二進位檔的幾個 Rust 字型 crate,所以 PDFium Component 會掃描載入的函式庫檔案,找 crate 名稱 skrifa 與 read-fonts(還有 read_fonts)。掃描只在指定 pfbpFontations 時執行,讀不起來的檔案一律當作「沒有 Fontations」

Fontations 檢查是啟發式的,只會往一個方向錯:某個 Fontations 建置要是把這些字串剝得一乾二淨,明明能用也會被拒。這個折衷是故意做的。誤拒的代價,是您接一個例外、退回 FreeType;誤收的代價,是整個行程

解除鎖定跟檢查本身一樣重要。LoadLibrary 在載入一開始就鎖定設定,若沒有這步重置,能力被拒之後 ConfigurePdfLibrary 會對每一次重試都回 EPdfError「PDFium library configuration is already sealed」。被拒的路徑會先呼叫 UnloadLibrary;此時呼叫 FPDF_DestroyLibrary 是安全的,因為 PDFium 還沒初始化,該呼叫立即返回。其他載入失敗,例如 DLL 缺檔或架構不符,則維持鎖定,重試迴圈必須分得清這兩種情況:

uses
  SysUtils, PDFium;

function StartPdfiumPreferringSkia: TPdfRendererPreference;
var
  Config: TPdfLibraryConfiguration;
begin
  Config := TPdfLibraryConfiguration.Default;
  Config.Renderer := prpSkia;
  ConfigurePdfLibrary(Config);
  try
    PDFium.LoadLibrary;   // 有單元限定:Windows.LoadLibrary 與它同名
    Result := prpSkia;
  except
    on E: EPdfError do
    begin
      // 能力被拒:先卸載 DLL、再解除設定鎖定。
      // 根本載不起來的 DLL 維持鎖定,重試也救不回來
      if PdfLibraryConfigurationSealed then
        raise;
      Config.Renderer := prpAgg;
      ConfigurePdfLibrary(Config);
      PDFium.LoadLibrary;
      Result := prpAgg;
    end;
  end;
end;

注意那個帶單元前綴的 PDFium.LoadLibrary。在同時 uses Windows 或 Winapi.Windows 的單元裡,不帶限定的 LoadLibrary 會解析成 uses 子句裡排最後的那個單元;要是解析到 Win32 函式,無參數呼叫會以參數數量錯誤編譯失敗,錯誤訊息隻字不提 PDFium

PDFium Component 的 LoadLibrary 預檢流程:ConfigurePdfLibrary 鎖定設定,能力檢查測試 FPDF_RenderPageSkia 匯出與 skrifa 字串證據,不支援的請求擲出可捕捉的 EPdfError 並解除鎖定供重試,而載入失敗的 DLL 讓 PdfLibraryConfigurationSealed 維持 true
驗證在匯出綁定之後、初始化之前執行,缺少的後端於是以您接得住的 EPdfError 失敗,而不是落在殺掉行程的原生 CHECK 上

更早就開始的驗證

ConfigurePdfLibrary 在任何 DLL 還沒登場前就會拒絕某些組合,一律以 EPdfError 拒之。明確指定 FontBackend(包括 pfbpFreeType)要求 Renderer = prpSkia,因為 PDFium 只在 Skia 渲染器下才理會字型後端。IsolatePerDocument 要求 V8Isolate 為 nil,因為 PDFium 會為每份文件自建 isolate,您再塞一個給它就會觸發原生 CHECK 失敗。UserFontPaths 裡的空字串會被拒。而首次載入嘗試之後的任何呼叫,都以「PDFium library configuration is already sealed」失敗

最後這條規則有個實際後果:您沒辦法先探一探 DLL、事後再設定。GetSkiaRenderCapabilities、V8FeaturesAvailable、開啟文件,以及大多數其他進入點,內部都會呼叫 LoadLibrary,當場就把設定鎖定;事後呼叫 UnloadLibrary 也不會重新打開。先設定、再載入、然後才發問——診斷用的程式碼本來就該照這個順序來:

uses
  SysUtils, PDFium, FPdfView;

function DescribePdfiumState: string;
var
  Config: TPdfLibraryConfiguration;
  Renderer: string;
begin
  Config := GetPdfLibraryConfiguration;   // 回傳的是複本,儘管檢視
  if not PDFium.Loaded then
  begin
    if PdfLibraryConfigurationSealed then
      Exit('PDFium failed to load; configuration is sealed');
    Exit('PDFium not loaded; configuration can still change');
  end;
  // 與 LoadLibrary 搭建 FPDF_LIBRARY_CONFIG 時同一套解析
  if PdfNativeRendererType(Config.Renderer,
    GetSkiaRenderCapabilities.PageRender) = FPDF_RENDERERTYPE_SKIA then
    Renderer := 'Skia'
  else
    Renderer := 'AGG';
  Result := Format('Renderer=%s Brotli=%s IsolatePerDocument=%s',
    [Renderer, BoolToStr(Config.BrotliEnabled, True),
     BoolToStr(Config.IsolatePerDocument, True)]);
end;

啟動時把那行寫進 log 一次成本極低,而支援單只要寫著「伺服器上文字長得不一樣」,您第一個想看的就是它。PDFium.Loaded 帶限定詞的理由與 LoadLibrary 相同:在表單或元件方法裡,裸的 Loaded 會綁到 TComponent.Loaded

帶版本的 C 設定結構出錯的兩種方式

每一種帶版本的設定結構——無論是 FPDF_LIBRARY_CONFIG、Win32 的 cbSize 記錄,還是外掛 ABI——都以兩種對稱的方式出錯,包裝層必須兩邊都防。第一種是填了欄位、版本卻留得太低;第二種是抬高了版本、欄位卻留在零值上,而被程式庫解讀成一個深思熟慮的選擇

  1. 欄位有填,版本太低。往版本 2 的結構裡寫 m_BrotliEnabled = 1,PDFium 根本不看它。呼叫成功,Brotli 串流繼續解不開。防法是讓版本從實際用到的欄位推導出來——LoadLibrary 就是這麼做的——而不是寫死一個數
  2. 版本夠高,零值就有意義。把版本抬到 6,版本 6 以前的所有欄位全部生效。FillChar 把 m_RendererType 歸零成 FPDF_RENDERERTYPE_AGG——那是個真實存在的渲染器,不是「未設定」。防法是對所選版本涵蓋的每個欄位都寫入有意為之的值,並對著實際建置去解析「預設」,而不是想當然

對可能讓被呼叫端當掉的值,還有第三條規則:在呼叫之前,用手上最強的證據對著二進位檔實際的能力驗證;證據若只是啟發式,就在程式碼與文件裡老實說。匯出的符號是證據;字串表裡的 crate 名稱只是有根據的猜測

速查:PDFium Component 程式庫設定

  • 在任何程式碼載入 DLL 之前呼叫一次 ConfigurePdfLibrary;任何能力查詢或文件載入都會鎖定設定
  • 若您設了 BrotliEnabled 或 IsolatePerDocument、又指望隨附執行期輸出 Skia 結果,請升級到 v3.123.0 以上
  • 除非需要特定光柵化器,否則讓 Renderer 留在 prpDefault;現在在任何結構版本下它都會解析成建置預設
  • 用 PdfNativeRendererType 配 GetSkiaRenderCapabilities.PageRender,把實際生效的渲染器寫進 log
  • v3.125.0 起,純 AGG DLL 上指定 prpSkia、或非 Fontations DLL 上指定 pfbpFontations,等著您的會是 EPdfError,不是當機
  • 能力被拒之後,PdfLibraryConfigurationSealed 為 False,可以重新設定;DLL 載入失敗之後則維持 True
  • 把 Fontations 偵測當啟發式看待,手邊留一條退回 FreeType 的路
  • PDFium.LoadLibrary 與 PDFium.Loaded 都帶上單元名稱,避開 Win32 與 TComponent 的同名衝突

如果 DLL 早在設定還談不上影響之前就失敗,先從診斷 Delphi 裡的 PDFium DLL 載入失敗下手;元件在各平台怎麼找到正確的二進位檔,在任何目標平台上載入 PDFium 原生函式庫有完整說明。渲染器的事底定之後,渲染快取與順暢縮放的招數會告訴您怎麼讓檢視器裡的頁面渲染保持快速

PDFium Component 為 Delphi 與 C++Builder 包裝 PDFium 引擎,並內建這類設定檢查,讓原生初始化失敗時擲出的是您處理得了的 Pascal 例外,而不是整個行程退出。產品細節與下載見 PDFium Component for Delphi 產品頁