技術文章

在 Delphi 中使用 PDFium VCL 建置 PDF 檢視器

在 Delphi 中的 PDF 檢視器歸結為兩個元件以及它們之間的串接。TPdf 擁有該文件:它負責開啟檔案、解密,並回答關於頁數和中繼資料的問題。TPdfView 是一個視覺控制項,它在螢幕上繪製頁面並處理滾動、縮放以及使用者目前正在觀看的頁面。PDFium VCL 包裝了與 Chrome 內部相同的渲染引擎,因此您在畫布上獲得的字形、反鋸齒和色彩,與您的使用者在瀏覽器中已經看到的內容相符。這些工作不在於渲染。而在於將文件物件連接到視圖,在遇到損壞或有密碼保護的檔案時能載入而不崩潰,並提供使用者一些控制項,讓這個檢視器感覺更完善:翻頁、改變縮放、將頁面適應視窗

這將按照您實際建置的順序逐步解說該組裝過程。這裡的一切一次只渲染一個頁面,這正是大多數文件工作流程所需要的。如果您需要將頁面堆疊在一個連續滾動的欄中,那就是不同的版面配置決策,並非此處的途徑

將 TPdf 串接至 TPdfView

TPdfTPdfView 拖曳到表單上,然後告訴視圖要顯示哪份文件。那個單一的指派就是非視覺的文件與繪製它的控制項之間的整個連結

procedure TFormMain.FormCreate(Sender: TObject);
begin
  // Pdf 和 PdfView 是在設計時拖曳上去的。
  PdfView.Pdf := Pdf;                 // 這個文件包含什麼,視圖就繪製什麼
  PdfView.FitMode := pfmFitWidth;     // 讓使用者從一個合理的縮放比例開始
end;

在執行這些操作之前,機器上必須先有 PDFium 的原生函式庫。PDFium VCL 會根據您的目標平台呼叫 pdfium32.dllpdfium64.dll,如果找不到 DLL,文件就會直接拒絕開啟。請將匹配的 DLL 附在您的執行檔旁邊,或是放在系統載入器可以找到它的地方。支援 V8 的建置版本僅適用於夾帶您想執行的 JavaScript 的 PDF,而普通的檢視器並不需要,因此除非您有具體原因,否則請使用標準的 DLL

在不信任輸入的情況下載入文件

直覺是將載入操作包裝在 try/except 中,並將拋出的例外視為失敗。這種直覺在這裡是錯的,一旦弄錯就會產生一個看起來很好,直到有人給它一個損壞的檔案才出問題的檢視器。設定 Active := True 不會在載入失敗時引發例外。PDFium VCL 會捕捉內部錯誤,並讓 Active 保持在 False 的狀態,因此了解文件是否開啟的唯一誠實方法,就是在設定之後讀回這個屬性

procedure TFormMain.OpenDocument(const FileName: string);
begin
  Pdf.FileName := FileName;
  Pdf.Active := True;                 // 永遠不會引發例外;失敗會讓 Active 保持為 False
  if not Pdf.Active then
  begin
    ShowMessage('無法開啟 ' + FileName);
    Exit;
  end;
  PdfView.PageNumber := 1;            // 視圖會追蹤自己的目前頁面
  UpdatePageLabel;
end;

有兩件事值得注意。第一是 PageNumber 同時存在於這兩個物件上,且兩者是獨立的。Pdf.PageNumber 是文件概念上的目前頁面;PdfView.PageNumber 則是控制項實際顯示的頁面,它是您用來讓使用者在檔案中移動的設定值。設定其中一個並不會移動另一個,因此檢視器總是驅動視圖的屬性。第二是 1 為基礎的索引:頁面是從 1 跑到 Pdf.PageCount,而不是從 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) then
    begin
      Pdf.Password := Password;       // 必須在 Active := True 之前設定
      Pdf.Active := True;
    end;
    if not Pdf.Active then
    begin
      ShowMessage('無法開啟文件。');
      Exit;
    end;
  end;
  PdfView.PageNumber := 1;
end;

因為錯誤密碼和損壞檔案的失敗都是無聲的,所以您無法單憑 Active 來區分兩者。實務上這對檢視器來說是可以接受的:使用者要不是提供正確的密碼,就是知道檔案無法開啟,不管怎樣訊息讀起來都是一樣的

在文件中分頁瀏覽

文件開啟後,導覽就是受 Pdf.PageCount 限制在 PdfView.PageNumber 上進行的算術運算。唯一的真正工作是箝制(clamping),如此一來按鈕就永遠不會把頁面推出範圍之外,並且第一頁和最後一頁的按鈕在檔案兩端會保持停用狀態

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 呼叫,而箝制則涵蓋了使用者在一份只有 10 頁的檔案中輸入 9999 的情況。請讓 UpdatePageLabel 成為唯一一個寫出「第 3 頁,共 12 頁」的地方,這樣讀數就永遠不會與視圖顯示的內容失去同步

縮放:明確的百分比與適應模式

TPdfView 上的縮放有兩種會互相影響的模式,而理解這兩者之間的互動,就是一個乖巧的縮放控制項與一個跟使用者對著幹的控制項之間的差別。直接的途徑是 Zoom 屬性,這是一個百分比,其中 100 代表實際大小。另一個途徑是 FitMode,它會告訴視圖為您計算縮放比例,並在視窗調整大小時持續重新計算

// 固定的放大倍率
PdfView.Zoom := 100;     // 實際大小
PdfView.Zoom := 50;      // 一半
PdfView.Zoom := 200;     // 兩倍

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

這是讓人跌倒的部分。直接指派 Zoom 會將 FitMode 重設為 pfmNone。這是正確的行為,而不是錯誤:當使用者選擇精確的 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.TitlePdf.Author 只要讀取屬性即可;旋轉與灰階是您將頁面繪製到點陣圖時所傳遞的渲染選項。這些都不會改變您在這裡的主幹,也就是文件物件、視圖,以及連接它們的「載入然後導覽」流程。把主幹弄對了,剩下的就只是裝飾而已

貫穿全文所使用的 TPdfTPdfView 元件是 Delphi 和 C++Builder 適用的 PDFium VCL 的一部分,其產品頁面上帶有完整的檢視器參考