技術文章

Build a PDF Viewer in Delphi with PDFium Component

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

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

將 TPdf 連接到 TPdfView

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

procedure TFormMain.FormCreate(Sender: TObject);
begin
  // Pdf and PdfView were dropped at design time.
  PdfView.Pdf := Pdf;                 // the view paints whatever this document holds
  PdfView.FitMode := pfmFitWidth;     // start the user at a sensible zoom
end;

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

載入文件而不信任輸入

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

procedure TFormMain.OpenDocument(const FileName: string);
begin
  Pdf.FileName := FileName;
  Pdf.Active := True;                 // never raises; failure leaves Active = False
  if not Pdf.Active then
  begin
    ShowMessage('Could not open ' + FileName);
    Exit;
  end;
  PdfView.PageNumber := 1;            // the view tracks its own current page
  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;       // must be set before 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.PageCountPdfView.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;

// the four navigation buttons reduce to one call each
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,它告訴檢視為您計算縮放比例,並在視窗大小調整時保持重新計算

// fixed magnifications
PdfView.Zoom := 100;     // actual size
PdfView.Zoom := 50;      // half
PdfView.Zoom := 200;     // double

// let the view size the page to the window, and keep it sized on resize
PdfView.FitMode := pfmFitWidth;   // page width fills the control
PdfView.FitMode := pfmFitPage;    // whole page visible
PdfView.FitMode := pfmActualSize; // 1:1 with the document's points

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

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

// seed a zoom readout from the fit-to-width value of the current page
var
  FitPercent: Double;
begin
  FitPercent := PdfView.PageWidthZoom[PdfView.PageNumber];
  ZoomEdit.Text := Format('%.0f%%', [FitPercent]);
end;

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

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

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

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