技術文章

PDFium 選填匯出:Delphi 中的能力關卡

你的 pdfium.dll 載入順利,卻依然缺一個程序。PDFium Component 用兩類繫結來處理這件事:必要匯出透過 CheckGetProcAddress 解析,缺少就直接中止載入;選填匯出透過 TryGetProcAddress 解析,缺少就留下一個 nil 指標,加上一項能力檢查

這和一個找不到的 DLL 不是同一個問題。如果你的應用程式死於一個壞的 EXE 格式錯誤、一個遺失的檔案,或是架構不符,那個故事寫在部署 pdfium.dll 與診斷載入失敗的姊妹篇裡。這裡的載入器成功了。模組控制代碼有效,好幾百個匯出都解析成功,但執行過程依然在你第一頁渲染出來之前就結束,因為某個在較新版 PDFium 建置裡才出現的進入點,不在磁碟上那份二進位檔裡

為何缺一個匯出會弄壞整個元件庫?

因為一個必要繫結是一份硬性合約,而它是在單一次全有或全無的綁定序列中被強制執行的。PDFium Component 在 LoadLibrary 裡解析整張匯出表,一次接一次呼叫 CheckGetProcAddress。第一個 nil 結果會丟出 EPdfError,並在此之前呼叫 UnloadLibrary,這是刻意的:一次部分綁定,否則會讓已經解析好的指標繼續指向一個即將被釋放的模組,悄悄打敗下游每一個 Assigned 防護

結果就是把人們帶到這裡的那種失效模式。你升級了元件,出貨的還是你出貨了兩年的同一份 pdfium.dll,應用程式就是啟動不了。錯誤訊息指名的是一個你從沒呼叫過的功能所需的匯出。你在呼叫端做什麼都沒用,因為呼叫端根本沒機會執行;失敗發生在綁定期間,早於任何文件被開啟

function CheckGetProcAddress(const Name: string): Pointer;
begin
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
  if Result = nil then
  begin
    // A missing required export means the deployed pdfium.dll is older
    // than this build of the binding. Drop every pointer resolved so far
    // so no caller can reach into the module we are about to free.
    UnloadLibrary;
    raise EPdfError.Create('Required PDFium export not found: ' + Name);
  end;
end;

function TryGetProcAddress(const Name: string): Pointer;
begin
  // Optional export. nil is a legitimate answer here; every caller is
  // required to test Assigned() before dereferencing the variable.
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
end;

必要或選填:分界線實際上畫在哪裡

PDFium Component 套用的規則很直白。一個匯出若缺席會讓元件無法完成它存在的工作,就是必要的;若缺席只會拿掉一項末端功能,就是選填的。FPDF_InitLibraryFPDF_LoadDocumentFPDF_RenderPageBitmapFPDF_ClosePage 都是必要的,在這些上面大聲失敗是對的:一個無法渲染的檢視器不是一個降級的檢視器,它是一個壞掉的檢視器

今天透過寬容載入器接觸到的每個東西都是末端功能。FPDFBookmark_GetColor 是 M109 之後才出現的,只提供大綱項目選填的 /C 顏色陣列,所以一份比它更早的 DLL,單純就是回報沒有大綱顏色。V8 輔助函式 FPDF_GetRecommendedV8FlagsFPDF_GetArrayBufferAllocatorSharedInstance,以及 XFA 字串輔助函式 FPDF_BStr_InitFPDF_BStr_SetFPDF_BStr_Clear,依構造在任何非 V8 建置裡都不存在,所以把它們當成必要,會讓純版 pdfium.dll 完全無法載入。還有促成這篇文章的那一對:FPDFAttachment_SetDescriptionFPDFAttachment_GetDescription,在 2026-07-13 於上游新增,晚於這個專案在 DLLs/Win32 與 DLLs/Win64 下出貨的全部四份 PDFium 二進位檔的建置日期。最後這個案例是這類問題的一般形狀,不是單一個案:繫結層追蹤持續變動的上游標頭,而你安裝程式裡的 DLL 卻是每次有人重新建置時才躍進一次。永遠會有一段視窗,在那段視窗裡 Pascal 端知道一些部署的二進位檔沒有的匯出,事先決定好每個新匯出該落在必要/選填分界線的哪一邊,才是讓那段視窗能被熬過去的唯一辦法

FPDFDoc_GetAttachmentCount    := CheckGetProcAddress('FPDFDoc_GetAttachmentCount');
FPDFDoc_AddAttachment         := CheckGetProcAddress('FPDFDoc_AddAttachment');
FPDFAttachment_GetName        := CheckGetProcAddress('FPDFAttachment_GetName');
FPDFAttachment_GetStringValue := CheckGetProcAddress('FPDFAttachment_GetStringValue');
// Attachment descriptions were added after the bundled DLL revision.
// Keep them optional so older deployments continue to load.
FPDFAttachment_SetDescription := TryGetProcAddress('FPDFAttachment_SetDescription');
FPDFAttachment_GetDescription := TryGetProcAddress('FPDFAttachment_GetDescription');
FPDFAttachment_SetFile        := CheckGetProcAddress('FPDFAttachment_SetFile');
FPDFAttachment_GetFile        := CheckGetProcAddress('FPDFAttachment_GetFile');

能力關卡在呼叫端該做什麼?

它該是不對稱的,而這種不對稱正是整個設計的核心。一次無法執行的讀取,有一個誠實的空答案。一次無法執行的寫入,卻沒有任何誠實的答案,所以它必須丟出例外。PDFium Component 正是沿著這條線,把附件描述屬性一分為二,而這個分割正是阻止一個缺少的匯出變成悄悄資料遺失的關鍵。TPdf.GetAttachmentDescription 測試 Assigned(FPDFAttachment_GetDescription),退出時給一個空的 WString。這不是說謊:在一份沒有這個匯出的 DLL 上,元件真的無法判斷這個附件是否帶有一個 /Desc 項目,而一個空描述讀起來,和一個從未有過描述的附件是一樣的。附件 API 的其餘部分,涵蓋於在 Delphi 中用 PDFium Component 處理 PDF 附件一文,維持不受影響地運作

TPdf.SetAttachmentDescription 走的是相反的路。它對同一個 Assigned 測試呼叫 Check,丟出帶有「Attachment descriptions are not supported by the loaded PDFium DLL」文字的 EPdfError。在這裡悄悄回傳會是最糟的選項:呼叫端設定了一個描述,沒得到任何錯誤,儲存了檔案,出貨了一份描述根本不存在的 PDF。沒人會察覺,直到某個下游消費者問它去哪了

function TPdf.GetAttachmentDescription(Index: Integer): WString;
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  Result := '';

  // Read side degrades: an old DLL cannot report /Desc, and '' is
  // indistinguishable from an attachment that carries no description.
  if not Assigned(FPDFAttachment_GetDescription) then
    Exit;
  // ... two-pass buffer sizing against FPDFAttachment_GetDescription ...
end;

procedure TPdf.SetAttachmentDescription(Index: Integer; const Value: WString);
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  // Write side refuses: silently dropping the value would produce a file
  // the caller believes carries a description and does not.
  Check(Assigned(FPDFAttachment_SetDescription),
    'Attachment descriptions are not supported by the loaded PDFium DLL');
  // ... FPDFDoc_GetAttachment, then FPDFAttachment_SetDescription ...
end;

在提供功能之前先探測能力

捕捉一個例外是發現你的部署能做什麼的爛方法,所以 PDFium Component 把同一項測試公開成一個具名函式。AttachmentDescriptionFeaturesAvailable 呼叫 LoadLibrary,回傳這一對函式是否都解析成功。它與 V8FeaturesAvailableXfaBStrHelpersAvailableXfaFeaturesAvailable 並列,為它們各自的選填群組遵循一模一樣的模式。命名這個探測比看起來更要緊:一個叫做 AttachmentDescriptionFeaturesAvailable 的布林值,告訴下一位維護者這項功能取決於部署的二進位檔,而藏在一個屬性設定器裡的一個裸 Assigned 測試永遠做不到這一點。它也給了 UI 層一個可以綁定的東西,讓描述編輯方塊能一開始就被停用,而不是接受輸入、卻在儲存時被拒絕

procedure TAttachmentFrame.SyncCapabilities;
begin
  // Ask once, at form setup, instead of discovering the limit on save.
  DescriptionEdit.Enabled := AttachmentDescriptionFeaturesAvailable;
  if not DescriptionEdit.Enabled then
    DescriptionEdit.TextHint := 'Requires a newer pdfium.dll';
end;

procedure TAttachmentFrame.SaveDescription(Pdf: TPdf; Index: Integer);
begin
  if not AttachmentDescriptionFeaturesAvailable then
    Exit;
  Pdf.AttachmentDescription[Index] := DescriptionEdit.Text;
end;

為何繫結涵蓋率必須靠工具證明?

因為這些數字已經超出人類能被信任處理的程度。PDFium Component 用一份 2026-07-29 的上游基準線,稽核了 21 份公開 PDFium 標頭,找到 470 個匯出的 C ABI 函式。繫結早已涵蓋其中 468 個。沒人靠讀標頭找到那個缺口 2 的差距;是一支程式在一秒內找到的,而它會在下一次上游躍進時再找一次。tools/audit_pdfium_public_api.py 刻意寫得很小:它用正規表達式在公開目錄的每個標頭裡比對 FPDF_EXPORT ... FPDF_CALLCONV name(,用正規表達式在 PDFium.pas 裡比對每個 CheckGetProcAddress('Name')TryGetProcAddress('Name'),印出兩個集合的差異:missing 代表沒有繫結的匯出,stale 代表繫結指向的匯出上游已經不存在。任一集合非空時它就以非零狀態退出,所以能不必額外儀式地掛進一個建置步驟。目前的結果是 470 中的 470 已綁定,missing 0,stale 0

stale 這個方向和 missing 一樣值得。一個上游移除的匯出,會留下一行 CheckGetProcAddress,未來每一次載入都會硬性失敗,而這種腐化在有人更新 DLL 那天之前都是隱形的。人工審查會找到你正在想的那個函式;它找不到你沒在想的那個。另外注意,這個稽核刻意把兩種載入器都算進涵蓋率裡,這對 API 漂移來說是正確的做法,也是為何必要/選填的分界必須是一項有文件記載的決策,而不是隨便誰加了那一行的副產品

選填繫結不再誠實的地方

有兩個邊界值得明講,因為這個模式很容易被過度套用。第一個是,一個 nil 函式指標,只有在字面上每一條碰它的路徑都先測試 Assigned 時才是安全的。在一個宣告了數百個 cdecl 函式變數的單元裡,單獨一次不設防的呼叫,就是一次在堆疊追蹤裡毫無意義位址上的存取違規。同樣的紀律支配著跨越 C 邊界的呼叫慣例與生命週期,這是強化 PDFium 繫結對抗 ABI 與記憶體安全故障一文的主題

第二個邊界是範圍。選填繫結不是一張讓一切都變寬容的通用許可證。如果 FPDF_RenderPageBitmap 是選填的,元件會愉快地載入,然後在每一頁都失敗,把一次清楚的啟動錯誤,變成一堆散落各處、原因不明的執行期錯誤。必要是正確的預設值。選填是你在一項功能真的是末端功能、缺席在讀取端有站得住腳的降級行為、而寫入端能用一則指名理由的訊息拒絕時,才會伸手用的例外

這裡描述的載入器設計、能力探測與稽核工具,都是 Delphi 與 C++Builder 版 PDFium Component 的一部分;產品頁面列出隨附的 PDFium 二進位檔與它們公開的完整 API 介面