技術文章

Build a PDF Viewer in Delphi with PDFium Component

在 Delphi 中,PDF 檢視器可以歸結為兩個元件以及它們之間的連接;TPdf 擁有文件:它開啟檔案、對其進行解密,並回答有關頁數和中介資料的提問;TPdfView 是在螢幕上繪製分頁並處理捲動、縮放以及使用者目前所看分頁的視覺控制項;PDFium 元件封裝了與 Chrome 內部隨附相同的算繪引擎,因此您在畫布上獲得的字形、消除鋸齒和色彩,與使用者在瀏覽器中看到的完全一致;工作重點不在於算繪,而是在於將文件物件連接到檢視、在損毀或受密碼保護的檔案上載入而不崩潰,並為使用者提供少數幾個使檢視器顯得完整的控制項:翻頁、變更縮放比例,以及讓分頁適應視窗大小

這將按照您實際建置的順序引導您完成該組合;此處的所有內容一次僅算繪一個分頁,這正是大多數文件工作流程所需要的;如果您需要分頁在一個連續捲動的資料行中堆疊,那就是不同的版面配置決策,而不是此處的路徑

將 TPdf 連接到 TPdfView

在表單上放置一個 TPdf 和一個 TPdfView,然後告訴檢視要顯示哪個文件;這單個指派就是非視覺文件與繪製它的控制項之間的完整連結

Delphi PDF 檢視器架構:TPdf 持有文件、TPdfView 負責繪製,一個屬性指派就把兩者連接在 PDFium DLL 之上
TPdf 持有文件,TPdfView 負責繪製,一個指定就讓兩者透過共用的 PDFium 引擎接通
procedure TFormMain.FormCreate(Sender: TObject);
begin
  // Pdf 與 PdfView 是在設計階段放置的。
  PdfView.Pdf := Pdf;                 // 檢視會繪製這個文件所持有的任何內容
  PdfView.FitMode := pfmFitWidth;     // 以合理的縮放比例開始
end;

在執行這些操作之前,PDFium 原生程式庫必須已存在於電腦上;PDFium 元件根據您的目標平台呼叫 pdfium32.dll 或 pdfium64.dll,如果找不到該 DLL,文件就會拒絕開啟;請將相匹配的 DLL 隨您的執行檔一起寄送,或將其放置在系統載入器可以找到的地方;啟用 V8 的建置版本僅適用於包含您想要執行的 JavaScript 的 PDF,而普通檢視器並不需要,因此除非您有具體的原因,否則請選用標準 DLL

載入文件而不信任輸入

本能反應是將載入操作包裝在 try/except 中,並將擲出的例外狀況視為失敗;這種本能在此處是錯誤的,弄錯了會導致檢視器看起來正常,直到有人給它一個損毀的檔案為止;設定 Active := True 在載入失敗時不會引發例外;PDFium 元件擷取了內部錯誤並讓 Active 保持為 False,因此知道文件是否開啟的唯一誠實方法是在設定後讀回該屬性

Delphi PDFium 檢視器的載入決策流程:設定 Active 永不擲出例外,靜默的 false 代表密碼錯誤或檔案損毀,接著可重試一次密碼
啟用失敗時絕不引發例外,檢視器因此回讀 Active,對沉默的 false 以單次密碼重試回應
procedure TFormMain.OpenDocument(const FileName: string);
begin
  Pdf.FileName := FileName;
  Pdf.Active := True;                 // 絕不會引發異常;失敗時 Active 保持 False
  if not Pdf.Active then
  begin
    ShowMessage('Could not open ' + FileName);
    Exit;
  end;
  PdfView.PageNumber := 1;            // 檢視追蹤它自己的目前分頁
  UpdatePageLabel;
end;

有兩點值得注意;首先,PageNumber 存在於這兩個物件上,且兩者是獨立的;Pdf.PageNumber 是文件目前分頁的概念,而 PdfView.PageNumber 是控制項實際顯示的分頁,也是您用來引導使用者瀏覽檔案的分頁;設定其中一個不會移動另一個,因此檢視器始終驅動著檢視的屬性;其次是基於 1 的索引:分頁範圍是從 1 到 Pdf.PageCount,而不是從 0 開始,這會讓任何習慣於基於 0 陣列的人措手不及

處理加密的檔案

加密的文件會融入相同的載入路徑中;如果開啟密碼在啟用前已設定,文件在開啟時會自動解密,如果密碼錯誤或缺失,Active 會像處理損壞的檔案一樣保持為 False;因此,還原的方法是提示輸入密碼並再次嘗試啟用

procedure TFormMain.OpenWithPassword(const FileName: string);
var
  Password: string;
begin
  Pdf.FileName := FileName;
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    if InputQuery('Password required', 'Password:', Password) then
    begin
      Pdf.Password := Password;       // 必須在 Active := True 之前設定
      Pdf.Active := True;
    end;
    if not Pdf.Active then
    begin
      ShowMessage('Unable to open the document.');
      Exit;
    end;
  end;
  PdfView.PageNumber := 1;
end;

因為對於錯誤的密碼和損壞的檔案來說,失敗都是無聲的,所以您無法僅憑 Active 來區分這兩者;在實踐中,這對於檢視器來說是可以接受的:使用者要麼提供正確的密碼,要麼得知檔案無法開啟,不論是哪種情況,訊息讀起來都是一樣的

在文件中進行分頁瀏覽

開啟文件後,導覽就是受限於 Pdf.PageCount 的 PdfView.PageNumber 的算術操作;唯一真正的工作是限制邊界,這樣按鈕就永遠不會將分頁推向超出範圍,且最前頁和最後頁按鈕在檔案末尾保持停用狀態

procedure TFormMain.GoToPage(NewPage: Integer);
begin
  if not Pdf.Active then
    Exit;
  if NewPage < 1 then
    NewPage := 1
  else if NewPage > Pdf.PageCount then
    NewPage := Pdf.PageCount;
  PdfView.PageNumber := NewPage;
  UpdatePageLabel;
end;

// 四個導覽按鈕各歸結為單一呼叫
procedure TFormMain.FirstClick(Sender: TObject);  begin GoToPage(1); end;
procedure TFormMain.PrevClick(Sender: TObject);   begin GoToPage(PdfView.PageNumber - 1); end;
procedure TFormMain.NextClick(Sender: TObject);   begin GoToPage(PdfView.PageNumber + 1); end;
procedure TFormMain.LastClick(Sender: TObject);   begin GoToPage(Pdf.PageCount); end;

「前往第 N 頁」文字方塊是饋送了剖析後整數的相同 GoToPage 呼召,且限制邊界涵蓋了使用者在十頁檔案中輸入 9999 的情況;請將 UpdatePageLabel 作為寫入「第 3 頁,共 12 頁」的唯一地方,這樣讀數就永遠不會與檢視顯示的內容失去同步

縮放:明確的百分比與符合模式

TPdfView 上的縮放有兩種相互作用的樣式,理解這種相互作用決定了縮放控制項是能乖乖聽話,還是與使用者作對;直接的路徑是 Zoom 屬性(以百分比表示,其中 100 表示實際大小);另一個路徑是 FitMode,它告訴檢視為您計算縮放比例,並在視窗大小調整時保持重新計算

PDFium Delphi 檢視器中 Zoom 與 FitMode 的互動:指定確切的 Zoom 會把 FitMode 清成 pfmNone,選擇適合模式則把縮放交還給檢視
指定精確縮放會清掉適配模式,選定適配模式則把縮放計算交還檢視
// 固定放大倍率
PdfView.Zoom := 100;     // 實際大小
PdfView.Zoom := 50;      // 一半
PdfView.Zoom := 200;     // 兩倍

// 讓檢視依視窗調整頁面大小,並在調整大小時保持
PdfView.FitMode := pfmFitWidth;   // 頁面寬度填滿控制項
PdfView.FitMode := pfmFitPage;    // 整頁可見
PdfView.FitMode := pfmActualSize; // 與文件的點數 1:1

這是容易讓人出錯的地方;直接指派 Zoom 會將 FitMode 重設為 pfmNone;這是正確的行為,而不是 Bug:在使用者選擇精確的 150% 的那一刻,檢視就不能再同時遵守「符合寬度」了,因為這兩個請求是衝突的;這對您的 UI 的後果是,放大按鈕和符合分頁按鈕是互斥的狀態,且工具列應使目前作用中的模式可見;當使用者點選符合分頁時,設定 FitMode,當他們點選數值縮放時,設定 Zoom 並讓它自行清除符合模式

如果您寧願自己計算符合值,例如為了用目前的符合百分比為縮放滑桿提供種子值,每個分頁的協助工具會為您提供數值而不用變更模式;PageWidthZoom[N]、PageZoom[N] 和 ActualSizeZoom[N] 回傳會使分頁 N 符合寬度、完整符合或以實際大小算繪的百分比

// 依目前分頁的符合寬度值為縮放讀數提供種子值
var
  FitPercent: Double;
begin
  FitPercent := PdfView.PageWidthZoom[PdfView.PageNumber];
  ZoomEdit.Text := Format('%.0f%%', [FitPercent]);
end;

完整的檢視器實際需要什麼

上面的檢視器只有幾十行,它已經完成了文件工作流程所需的工作:開啟檔案、在損毀的檔案中存活、顯示頁面、在頁面之間移動,以及手動或符合地變更放大倍率;PDFium 默默地完成了困難的部分;嵌入的字型得以解析,註記和表單欄位在文件放置它們的地方進行繪製,且您看到的頁面與 Chrome 使用者看到的完全一致,因為這兩者是相同的引擎繪製的

在此基礎上,新增的內容是增量式而非結構式的;文字選取與搜尋讀取自 PDFium 已經建置的相同文字層,像 Pdf.Title 和 Pdf.Author 這樣的中介資料只需讀取一個屬性即可,旋轉和灰階是您在將分頁繪製到點陣圖時傳遞的算繪選項;這些都不會改變您在此處擁有的骨架,即文件物件、檢視以及連接它們的載入然後導覽流程;處理好這個骨架,其餘的都是裝飾

此處顯示的 TPdf 和 TPdfView 元件是適用於 Delphi 和 C++Builder 的 PDFium 元件 的一部分,其產品頁面上攜帶了完整的檢視器參考