技術文章

PDFlibPas DLL、ActiveX 與 dylib 綁定:從任何語言呼叫單一 PDF 引擎

當一個 PDF 函式庫離開其原生語言時,這個問題就會立刻浮現。您有一個在 Windows 上從 C# 運作得完美無瑕的綁定 (binding)。您需要在 macOS 上從 Python 進行相同的呼叫,所以您複製了 Windows 宣告檔,交換了二進位檔案名稱,然後執行它。每個符號 (symbol) 都解析成功了。但第一次呼叫傳回垃圾,第二次呼叫伴隨著存取違規 (access violation) 崩潰了,而您的任何 PDF 程式碼都沒有改變。錯誤發生在 PDF 的下一層:Windows 匯出使用 Stdcall 慣例 (convention),macOS dylib 以帶有前導底線的 Cdecl 匯出相同的函式,而任何將這兩者之一弄錯的外部函式 (foreign-function) 宣告,都會在開啟任何一份文件之前就破壞堆疊 (stack)

這整類失敗來自於一個值得事先了解的設計決策。PDFlibPas(losLab 為 Delphi 和 C++Builder 提供的可取得原始碼 PDF 引擎)將其整個物件模型包裝在單一個扁平化的外觀 (facade) 類別 TPDFlib 中,然後以三種二進位形狀發布這個外觀:一個具有約 1,250 個匯出函式的 Windows DLL、一個 COM/ActiveX 自動化物件 (automation object),以及一個 macOS dylib。PDF 的語意在三者之間是完全相同的。會咬您的部分存在於底層的 ABI 中:呼叫慣例 (calling conventions)、字串編碼、控制代碼所有權 (handle ownership),以及哪一方被允許釋放哪個緩衝區

單一外觀,三種二進位形狀

TPDFlib 的每個公開函式都有一個扁平的對應項目,命名為 DL 加上方法名稱。LoadFromFile 變成 DLLoadFromFileEncrypt 變成 DLEncryptNewSignProcessFromFile 變成 DLNewSignProcessFromFile。幾乎每個匯出項目的第一個參數都是一個由 DLCreateLibrary 傳回的 InstanceID,它代替了 Delphi 呼叫者原本會持有的物件參考。請及早內化這個對應關係。這意味著 Delphi API 參考手冊同時也兼作其他每一種語言的文件:無論該類別能做什麼,DLL 都能在一個可預測的名稱下做同樣的事,而您可以透過閱讀 Pascal 方法簽章,來了解您從 Python 或 C# 呼叫時所需要的內容

Windows 的建置會產生 PDFlibDLL32.dllPDFlibDLL64.dll;請選擇符合您主機處理程序位元數 (bitness) 的那一個,因為無論宣告看起來如何,64 位元的 Java 或 .NET 處理程序都無法載入 32 位元的函式庫

Windows:Stdcall 執行個體與 W/A 函式對

每個接受字串的匯出項目都有兩個版本。一個寬位元 (wide) 版本接受 PWideChar(UTF-16,這是 .NET、Java 和 Python 的 c_wchar_p 最自然的搭配),以及一個帶有 A 字尾的版本接受 PAnsiChar。這兩者帶有完全相同的語意,僅在編碼上有所不同,這正是讓混合使用它們變得如此痛苦以至於難以追蹤的原因:沒有任何東西會拋出例外 (throw),沒有任何東西會傳回錯誤碼,您只是會在元資料中得到亂碼 (mojibake),或者對於任何路徑中帶有超出純 ASCII 字元的情況,得到一個虛假的「找不到檔案」錯誤。一個團隊以這種方式遇到的第一個編碼錯誤,通常會耗費一個下午的時間,因為症狀指向資料,而原因卻在宣告中

// Windows binding (PDFlibDLL64.dll): Stdcall, plain export names
function DLCreateLibrary: Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
  FileName, Password: PWideChar): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLLoadFromFile';

// macOS binding: same function, Cdecl, and an underscore prefix on the export
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

請為每個主機選擇一種字元寬度,並將其編寫進綁定產生器中。一條實用的規則:如果主機語言具有原生的 UTF-16 字串,那麼在所有地方都綁定 W 版本,並且永遠不要再碰 A 家族

macOS:相同名稱,不同的 ABI

dylib 匯出相同的 DL 函式集,但帶有兩個系統性的變更。呼叫慣例是 Cdecl 而不是 Stdcall,且每個匯出名稱都帶有一個前導底線(_DLCreateLibrary_DLLoadFromFile 等等)。這兩個變更都純粹是機械性的,這使它們非常適合用於產生的綁定,但對於手動編輯的 Windows 檔案副本來說卻很危險。如果您的工具允許,請保留一份權威的 (canonical) 函式清單,並從中發出 (emit) 每個平台的宣告。如果您跳過這個步驟,您就會得到如本頁頂端所描述的那種堆疊破壞,而且只會在您的 CI 剛好最少練習到的平台上重現

COM 與 ActiveX 主機:Safecall 與 Olevariant 負載

對於 VB.NET、C#、VBScript 以及舊有的自動化主機,OCX 建置會將相同的外觀包裝在一個 IDispatch 自動化物件 IPDFlibrary 中,每個方法宣告為 Safecall。這個慣例改變了錯誤到達您手上的方式。Safecall 會將內部失敗轉換為 COM HRESULT,所以 C# 呼叫者會捕捉到例外 (exception);但在扁平的 DLL 中,該處原本會傳回一個安靜的整數,呼叫者必須記得檢查它。相同的操作,兩種失敗慣用語,取決於您載入了哪個二進位檔

二進位資料遵循第二條特定於 COM 的規則。自動化介面完全沒有指標參數。任何二進位內容,無論是進入的圖片位元組或輸出的 PDF 位元組,都會作為 Olevariant 越過邊界,透過像 AddImageFromVariantAppendToVariant 等方法。在 .NET 中,將位元組陣列封送 (marshaling) 進入變體 (variant) 只需要一行程式碼。若是試圖遞給它一個原始指標(理由是反正它在同一個處理程序中),發送層 (dispatch layer) 會拒絕或弄壞該呼叫。還有一個註冊細節會絆倒部署作業:COM 註冊是區分位元數的,因此使用 32 位元 regsvr32 註冊的 OCX 對 64 位元主機是隱形的。這種不匹配會在客戶機上以出了名沒幫助的「類別未註冊」浮現,那是在它離開您的機器很久之後的事了

控制代碼紀律:執行個體擁有文件

扁平 API 運作在整數控制代碼 (handles) 上。DLCreateLibrary 傳回一個執行個體。載入檔案會傳回該執行個體內部的一個文件 ID。簽章處理程序、字串清單與直接存取檔案各自傳回它們自己的整數控制代碼,所有這些控制代碼都隸屬於同一個執行個體。從任何 FFI 主機來看,生命週期看起來都一樣,此處以 Pascal 顯示是因為它讀起來很乾淨:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // one instance per worker thread
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // returns a DocumentID, 0 on failure
    if Doc <> 0 then
    begin
      DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
        DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
      DLSaveToFile(Inst, 'out.pdf');
    end;
  finally
    DLReleaseLibrary(Inst);                      // frees every document the instance owns
  end;
end;

從該所有權樹狀結構可以推導出兩件事。DLReleaseLibrary 是您唯一嚴格需要的清理呼叫,因為它會一次性地拆除該執行個體下的每一份文件與程序控制代碼。在簡短的指令碼中,這就足夠了。在長期執行的服務中,它會變成一個帶有額外儀式的緩慢洩漏 (leak),所以請在用完文件後就釋放它們,而不是讓它們堆積直到執行個體死亡為止。執行個體也是執行緒隔離 (thread isolation) 最自然的單位。給予每個工作執行緒 (worker thread) 它自己的 InstanceID,且在沒有外部鎖定的情況下,永遠不要跨執行緒共用一個執行個體,理由就如同您永遠不會在執行緒之間共用單一個 TPDFlib 物件一樣

傳回的字串是借來的,並非擁有

傳回文字的函式(例如 DLGetPageText)會交回一個 PWideCharPAnsiChar,該指標指向由函式庫執行個體所擁有且會回收的緩衝區。合約是:請立即複製,永遠不要釋放它

var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // pointer into a library-owned buffer
  PageText := P;                 // copy now; a later call may reuse the buffer
end;

在 C# 中,這意味著在下一個函式庫呼叫之前,將 IntPtr 封送到一個受管理的字串 (managed string) 中。在 Python ctypes 中,這意味著立刻將寬字串從指標中切分 (slicing) 出來。如果跨越多個呼叫仍持有該原始指標,您就寫下了一個通過每一次單元測試,然後在生產環境中首次出現兩個請求重疊時就失敗的 bug,因為第二個呼叫回收了第一個呼叫仍在讀取的緩衝區。相同的擁有權規則也以相反的方向適用於透過 DLSetProgressCallback 註冊的回呼 (callbacks)。函式庫交給您回呼的任何指標,僅在該回呼的主體內有效;而該回呼物件本身必須維持存活狀態(在擁有垃圾收集器的主機中被釘選 / pinned),直到執行個體仍有可能呼叫它為止。一個在工作中途被收集的委派 (delegate),正是出現在一個已乾淨運行數月的 .NET 綁定中那種「隨機」存取違規的教科書來源

將冒煙測試 (smoke test) 內建在綁定本身,並在發布任何產生的宣告集之前執行它。練習每一種類別中容易暴露 ABI 錯誤的一個呼叫:一個無參數的函式(例如 DLCreateLibrary)來證明慣例是正確的;一個接收字串的函式,並餵給它一條帶有非 ASCII 字元的路徑,以證明編碼是正確的;一個傳出字串的函式來證明對借用緩衝區的處理是正確的;以及一個故意失敗的操作,這樣您就可以觀察錯誤是如何到達您的主機的。這只需要十五分鐘的工作,就能抓住那些如果沒有它,就會在幾個月後以客戶崩潰傾印 (crash dump) 形式抵達的呼叫慣例與編碼錯誤

以 Python ctypes 為例

Python ctypes 是我最常看到被手動編寫的綁定,它讓跨平台的分歧變得容易示範。在 Windows 上,使用 ctypes.WinDLL 載入函式庫,因此 ctypes 會套用 Stdcall,綁定沒有字尾的 W 函式,並將每個字串參數宣告為 c_wchar_p。在 macOS 上,使用 ctypes.CDLL 載入以用於 Cdecl,保留相同的函式清單,並解析沒有前導底線的名稱。大多數的 FFI 層(包含 ctypes)在 macOS 上都會為您把底線慣例摺疊回來,但那正是在您於其上產生數百個宣告之前,需要透過單一個成功解析的呼叫來確認的一個假設

有兩個部署問題緊隨在綁定工作之後,且擁有清晰的答案。扁平的 DLL 不需要註冊:regsvr32 僅適用於 ActiveX 建置,而 DLL 則是透過檔案複製來發布,這也是在 Windows 服務與容器(在這些環境中您寧可完全不碰登錄檔)中偏好使用它的主要原因。執行緒安全 (thread safety) 可化約為上面已經發揮作用的規則:每個執行緒一個執行個體。執行個體控制代碼持有引擎所追蹤的每一個可變狀態 (mutable state):選取的文件、渲染選項、提取設定,所以兩個共用一個執行個體的執行緒會交錯彼此的狀態,即使每個獨立的呼叫都傳回成功也是如此

一旦綁定穩固了,位在它另一端的操作正是 Delphi 文章深入探討的那些,包含套用與稽核 PDF 加密以及從現有文件中提取文字與圖片

所有三個整合層的二進位下載檔案都隨附於函式庫中;關於版本與授權,請參見 PDFlibPas 產品頁面