技術文章

Lazarus 與 Free Pascal 的 PDFium PDF 檢視器

Delphi 與 Lazarus 編譯的是同一種 Object Pascal,而正是這份表面上的相似,讓檢視器在兩者之間的移植格外容易騙人。這兩條工具鏈在三個對 PDF 工作要緊的地方分道揚鑣:原生的 string 型別在 Delphi 中是 UTF-16,在 LCL 應用程式中則是 UTF-8;VCL 與 LCL 是兩套不同的視覺框架,各有自己的控制項、對話方塊與表單串流格式;而 Delphi 的二進位檔以 Windows 為目標,FPC 的二進位檔則可能是要去 Linux 或 macOS。這些差異沒有一項會在編譯時現形。一個建立在 PDFium Component 之上的檢視器(它從單一原始碼樹出貨 VCL 與 LCL 兩個版本),在換掉少數幾個單元名稱、加上幾個 {$IFDEF FPC} 區塊之後,就能在 Lazarus 下乾淨地編譯過去。失敗來得比較晚,晚到真實資料與真實部署把 Delphi 建置版本默默做出的那些假設攤在陽光下

其中四項假設佔掉了大部分被浪費掉的時間:UI 邊界上的文字編碼、維護兩份表單副本的誘惑、原生引擎二進位檔在執行期的解析方式,以及當 SAPI 不在時,文字轉語音沒了平台可用的那一刻。每一項在您預期得到時處理起來都很便宜,在您沒預期到時追起來都很昂貴

同一種 Pascal,不同的字串酬載

Delphi 的原生 string 自 2009 年起就是 UTF-16。Lazarus 與 Free Pascal 在 LCL 應用程式中預設採用 UTF-8。元件面向文字的 API 透過 WString 型別說 UTF-16,而 FPC 建置版本把它別名為 WideString,所以文字在您的 LCL UI 與 PDF 引擎之間穿越的每一道邊界,都是一個轉換點

在直來直往的指派中,轉換是自動發生的,多數程式碼根本不必去想它。有兩個習慣能把編碼臭蟲擋在門外。讓文字原封不動地穿過去,不要在位元組層級動它:以位元組位移切分搜尋詞的程式碼在 Delphi 裡沒問題,因為一個 Char 就是一個 UTF-16 單位,但在 LCL 中會把多位元組的 UTF-8 弄壞。另外,從第一次執行起就用非 ASCII 資料測試。一個德文檔名、一個西里爾字母的搜尋詞、文件中繼資料裡一個帶重音的作者名:純 ASCII 的測試資料會藏起每一個編碼缺陷,因為 ASCII 正是 UTF-8 與 UTF-16 逐位元組一致的那個區段。臭蟲自始至終都在,ASCII 只是讓它隱形,直到慕尼黑的某位客戶打開一份您從沒試過的檔案

示意圖:Lazarus 中 LCL 檢視器 UI 與 PDFium 元件之間 UTF-16 與 UTF-8 的字串轉換邊界
元件的文字 API 透過 WString 說 UTF-16,所以持有 UTF-8 字串的 LCL UI 在每道邊界都會碰上一個轉換點,而位元組位移切分與只用 ASCII 的測試資料,正是編碼臭蟲藏身之處

一個條件式區塊,而不是每個 IDE 一個分叉

寫到第十幾個 IFDEF 之後,程式碼庫開始有種「兩個專案穿著同一個版本庫」的感覺,於是按 IDE 分叉看起來很誘人。那是錯的一步。真正的差異可以收攏進一個共用的宣告區塊,而分叉會從此把每一次修臭蟲的成本翻倍。請把條件式那一層維持得這麼小:

{$IFDEF FPC}
uses
  LCLType, Forms, Graphics, Controls;

type
  WString = WideString;   // 元件的文字 API 走 UTF-16
  TBytes  = array of Byte;
{$ELSE}
uses
  Winapi.Windows, Vcl.Forms, Vcl.Graphics, Vcl.Controls;
{$ENDIF}

那個區塊以下的一切,在兩個 IDE 中都編譯成相同的東西。文件處理、頁面導覽、呈現呼叫:TPdfTPdfView 在 VCL 與 LCL 版本中暴露相同的介面,所以檢視器的大部分程式碼從來看不到一個編譯器條件。維持這個狀態靠的是結構上的紀律,不是什麼聰明的技巧。共用的 PDF 邏輯住在不引入任何框架專屬對話方塊或面板的單元裡。少數真正不同的東西,例如帶著各平台慣例的列印對話方塊與檔案挑選器,則藏在一層薄薄的介面之後,每個框架各實作一次。那個 IFDEF 區塊,於是成為日後平台分歧唯一獲准落腳的地方,而不是讓編譯器指示詞滲進四十個單元裡

用程式碼建構表單,別在兩個設計工具裡各畫一次

表單串流正是雙 IDE 專案默默腐爛的地方。一個 .dfm 與一個 .lfm 宣稱描述同一張表單,卻一個屬性一個屬性地漂開,直到兩個建置版本行為不同,而沒有人 diff 得出原因,因為那兩個檔案根本不是同一種格式。在執行期建構檢視器可以繞開整個問題。您有唯一一套建構順序,以普通程式碼納入版本控制,而且在兩個平台上讀起來一模一樣:

procedure TViewerForm.FormCreate(Sender: TObject);
begin
  Pdf := TPdf.Create(Self);

  PdfView := TPdfView.Create(Self);
  PdfView.Parent := Self;
  PdfView.Align := alClient;
  PdfView.Pdf := Pdf;
  PdfView.FitMode := pfmFitWidth;

  if ParamCount > 0 then
  begin
    Pdf.FileName := ParamStr(1);
    Pdf.Active := True;   // 開啟文件;在這之後 PageCount 才有效
  end;
end;

那些指派的確切順序,遠不如真正做事的那一行來得要緊。PdfView.Pdf := Pdf 把視覺控制項繫結到文件元件,從那一刻起,透過 PageNumber 的頁面導覽與透過 FitMode 的貼合行為,在 VCL 與 LCL 之下反應完全相同。有一個跨框架的怪癖值得在使用者把它報成臭蟲之前先知道:手動指派 Zoom,在兩個框架上都會讓 FitMode 彈回 pfmNone。所以如果您的工具列把「符合寬度」當成一個黏著的偏好設定,就必須在任何程式化縮放之後重新指派貼合模式,否則程式碼第一次碰到縮放層級時,那個偏好就悄悄不再黏了

IDE 從來沒警告過您的那個二進位檔

這個元件包裝的是 PDFium 引擎,而它以原生平台二進位檔的形式出貨,那個二進位檔幾乎是每一份「在 IDE 裡好好的,從安裝好的捷徑啟動就掛掉」回報的來源。三條規則解釋了其中大部分。位元數必須完全相符。32 位元的可執行檔載入不了 64 位元的 pdfium 程式庫,而作業系統交回的訊息(某些 Windows 版本上是「找不到模組」)會積極誤導人,因為那個檔案就好端端地躺在可執行檔旁邊。請相對於可執行檔解析程式庫路徑,絕不要相對於工作目錄;從 IDE 啟動與從殼層啟動,差別恰恰就在這一點,這也是為什麼這個臭蟲在開發期間躲得好好的。還有,請在第一份文件開啟之前就抓到載入失敗,然後把預期路徑與架構明明白白寫進回報裡。一張寫著「PDFium 64-bit binary missing at <path>」的支援工單,幾分鐘就結案。一張寫著「檢視器一啟動就當掉」的,會變成一整週的來回拉鋸

順手把引擎二進位檔和可執行檔一起做版本控管。PDFium 進展很快,而一個只更新應用程式、卻把過期程式庫留在磁碟上的安裝器,會製造出您辦公室裡沒人重現得出來的當機,理由很簡單:您辦公室的每一台機器剛好都握著相配的那一對。請把這個程式庫當成建置產物的一部分,用同一個安裝器、同一個版本戳記,以及與載入它的可執行檔相同的回退路徑

示意圖:Lazarus 或 Delphi 的 PDF 檢視器可執行檔載入 PDFium 原生二進位檔的三條規則
三條規則涵蓋了大部分「IDE 裡好好的、裝起來就壞」的回報:可執行檔與 PDFium 程式庫必須位元數一致、程式庫路徑要從可執行檔而非工作目錄解析,以及載入失敗時要連預期路徑一起明白抓出來

在 Lazarus IDE 中註冊元件

執行期建構完全不需要設計期註冊,對一個以程式碼自建 UI 的檢視器來說,這是最乾淨的設定。當您確實想把元件放上 Lazarus 元件面板做設計期工作時,請安裝套件,並讓它專屬的註冊單元——Lib/FPC/PDFiumLaz.lpk 中的 PDFiumLazReg——去處理。那個單元被刻意標記為設計期單元:它參照了絕不該連結進您出貨可執行檔的 IDE 屬性編輯器介面

這件事做錯,症狀就是一個莫名其妙相依於 IDE 套件的應用程式,而它會在第一台從沒裝過 Lazarus 的客戶機器上,以部署失敗的形式現形

離開 Windows 之後的語音與螢幕閱讀器

文字轉語音是跨平台故事唯一破功的功能,而且它破在作業系統,不在元件。SAPI 這個 Windows 上常見的 TTS 後端,只存在於 Windows。仍以 Windows 為目標的 Lazarus 建置版本,保有完整的 SAPI 輸出與 Delphi 原版一樣的 NVDA 相容行為,所以 Windows 移到 Windows 的移植在這裡什麼都沒損失,NVDA 使用者也分不出這兩個建置版本

以 Linux 或 macOS 為目標則是另一回事。那裡沒有 SAPI 可呼叫,所以音訊輸出必須改接到原生語音服務,而它上面那些閱讀 API 則原地不動。這道切分,正是從第一次提交起就把語音藏在一個介面之後的理由:閱讀順序分析與字詞追蹤游標都與平台無關,可以原封不動帶過去,只有真正發出聲音的那一薄層需要逐平台更換。無障礙閱讀器一文深入涵蓋了那套閱讀機制

PDFium Component 示意圖:在 Lazarus 的 PDF 檢視器中,把 TTS 輸出移到逐平台引擎之後的語音介面切分
閱讀順序分析與字詞追蹤游標維持與平台無關,而一層薄薄的語音介面在 Windows 上解析為 SAPI,在 Linux 與 macOS 上解析為原生語音服務

宣布移植完成之前的一份對等檢查清單

下面這一輪檢查抓到過真實的回歸,大致按失敗浮現的順序排列。打開一份路徑含非 ASCII 字元的文件。用含非 ASCII 字元的詞搜尋,並確認命中處標示在它們該在的位置。在您出貨的每一種介面工具集上,都演練滑鼠滾輪捲動、拖曳選取與鍵盤頁面導覽,因為焦點處理與滾輪行為是 LCL 中最依賴介面工具集的角落。在 100%、150% 與 200% 的顯示縮放下檢查呈現結果。最後,在一台從沒裝過 IDE 的機器上執行安裝好的建置版本,而不是 IDE 的建置版本,因為那是唯一一項誠實演練二進位檔解析的測試。其他每一項都可能通過,而那一項悄悄失敗

呈現吞吐量在兩個版本之間原樣延續,所以呈現快取與縮放效能一文中的快取作法,可以照著為 VCL 寫的樣子原封不動套用在 LCL 檢視器上

以上這些都不會讓 LCL 版本矮一截。核心介面在兩邊完全相同:TPdfTPdfView、呈現、表單、文字擷取與各項無障礙 API,不論由哪個 IDE 編譯,行為都一樣。每一項值得追蹤的差異,都繫於平台而非繫於版本。SAPI 語音只限 Windows、對話方塊各自遵循框架慣例,而二進位檔必須與載入它的架構相符。把編碼邊界、執行期表單與二進位檔解析這三件事做對,移植剩下的部分,就是編譯器早已替您處理掉的那些機械工作

本文描述的 VCL 與 LCL 兩個版本一同以 PDFium Component 出貨,附原始碼,並為 Delphi、C++Builder 與 Lazarus/FPC 提供完全相同的公開 API