技術文章

在 Delphi 中使用 PDFium 元件進行雙頁 PDF 比較

同時開啟兩個文件、相同的頁碼、各自顯示在獨立的捲動面板中:這正是比較檢視器的核心;PDFium 元件透過簡單的物件模型來實現此功能,其中 TPdf 擁有檔案,而 TPdfView 擁有顯示;一個文件、一個 TPdf、一個 TPdfView;如果您需要三個面板,您就需要三組;最難的部分不是 API 呼叫,而是視窗調整大小時的版面計算,以及當您決定哪個檢視應該跟隨哪個檢視時的頁面同步邏輯

表單配置

VCL 表單並排容納了三個 TScrollBox 容器,每個容器內部都有一個 TPdfView,並將對齊方式設定為 alClient 以填滿容器;兩個 TSplitter 元件位於容器之間,以便使用者可以在執行階段調整資料欄寬度;面板上方的工具列包含了開啟按鈕、縮放控制項,以及雙頁/三頁檢視的切換開關

三頁檢視模式是表單在內部追蹤的布林值;當它切換時,您需要重新計算寬度並顯示或隱藏第三欄;最簡單的方法是清除所有 Align 屬性、隱藏分割器,然後設定絕對位置:

procedure TFormMain.UpdateLayout;
var
  TotalWidth: Integer;
begin
  TotalWidth := ClientWidth;

  if ThreeViewMode then
  begin
    ScrollBox3.Visible := True;
    ScrollBox1.Left   := 0;
    ScrollBox1.Width  := TotalWidth div 3;
    ScrollBox2.Left   := ScrollBox1.Width;
    ScrollBox2.Width  := TotalWidth div 3;
    ScrollBox3.Left   := ScrollBox2.Left + ScrollBox2.Width;
    ScrollBox3.Width  := TotalWidth - ScrollBox3.Left;
    // Apply the same (ClientHeight - toolbar height) to all three Height values
  end
  else
  begin
    ScrollBox3.Visible := False;
    ScrollBox1.Left   := 0;
    ScrollBox1.Width  := TotalWidth div 2;
    ScrollBox2.Left   := ScrollBox1.Width;
    ScrollBox2.Width  := TotalWidth - ScrollBox2.Left;
  end;
end;

在進行整數運算之前,將所有三個容器設定為 Align := alNone,可以避免 VCL 條件限制引擎影響您的指派;如果您希望在雙頁檢視模式下使用拖曳調整大小,請在定位後恢復分割器的可見性

每個捲動方塊的高度是主區域減去工具列面板的高度;因為工具列以 alTop 停靠在頂部,所以 ClientHeight - PanelButtons.Height 可以得到可用的垂直空間;在同一個 UpdateLayout 呼叫中將此值指派給所有三個方塊,這樣就不會出現某個方塊比其他方塊高而導致版面閃爍的情況

開啟文件

每對面板都需要自己的開啟程序;此模式非常簡短:停用元件、設定檔名、啟用,然後檢查 Active;如果它保持為 False,則提示輸入密碼並重試;請注意,TPdfView.Active 控制的是轉譯,而 TPdf.Active 才是真正開啟檔案的操作,這兩者是獨立的;在其連結的 TPdf 尚未啟用時將 PdfView.Active := True 設定為 True 是無害的,但不會顯示任何內容

procedure TFormMain.OpenPdfFile(PdfComponent: TPdf;
  PdfViewComponent: TPdfView);
var
  Password: string;
begin
  if not OpenDialog.Execute then
    Exit;

  PdfComponent.Active   := False;
  PdfComponent.FileName := OpenDialog.FileName;
  PdfComponent.Password := '';
  PdfComponent.Active   := True;

  // Load failures are silent: Active stays False instead of raising.
  if not PdfComponent.Active then
  begin
    // Most likely a password-protected file; give the user one retry.
    if InputQuery('Password', 'Enter document password:', Password) then
    begin
      PdfComponent.Password := Password;
      PdfComponent.Active   := True;
    end;
  end;

  if not PdfComponent.Active then
  begin
    ShowMessage('Could not open ' + OpenDialog.FileName +
      ' (damaged file or wrong password)');
    Exit;
  end;

  PdfViewComponent.PageNumber := 1;
  SetActivePdfView(PdfViewComponent);
end;

在指派後務必檢查 PdfComponent.Active;損毀的檔案或錯誤的密碼會導致載入靜默失敗,在預設路徑下不會引發異常;在成功開啟後明確設定 PdfViewComponent.PageNumber := 1,可以避免使用上一個文件遺留的舊頁碼

結尾的訊息對話方塊是有意設計的:您希望損毀或不受支援的檔案能立即呈現,而不是被吞掉而只顯示一個空白面板;什麼都看不到的使用者將無法得知檔案是已載入但剛好是空的,還是元件拒絕了它;回報失敗可以讓錯誤保持可見

當前活動面板追蹤

當使用者在面板內部點擊時,該面板就會成為活動面板;表單追蹤一個私有的 FActivePdfView: TPdfView 欄位;視覺回饋是容器 TScrollBox 的邊框顏色變更:將活動的邊框設定為 clHighlight,其他則設定為 clWindow;將此事件連接到每個 TPdfView.OnClick 和開啟程序中,以便焦點跟隨您剛剛開啟的文件

某些作業適用於所有可見的面板,而不僅僅是活動面板;表單上的布林值 FAllViewsMode 會驅動該分支;當它為真時,縮放變更和頁面巡覽將會展開到每個具有作用中文件的面板:

procedure TFormMain.ApplyZoomToAll(NewZoom: Double);
begin
  if PdfView1.Active then PdfView1.Zoom := NewZoom;
  if PdfView2.Active then PdfView2.Zoom := NewZoom;
  if ThreeViewMode and PdfView3.Active then PdfView3.Zoom := NewZoom;
end;

同步頁面巡覽

同步巡覽是選用的,但對於兩個檔案涵蓋相同頁面範圍的文件修訂工作流程非常有用;此邏輯存在於使用者巡覽某個檢視後觸發的事件處理常式中;當來源檢視變更其 PageNumber 時,處理常式會將該頁碼傳播到其他檢視,但有一個保護條件:目標檢視必須至少有那麼多頁,否則跳過

TPdfViewTPdf 上的 PageNumber 是獨立的;TPdf.PageNumber 追蹤文件元件認為當前的頁面,而 TPdfView.PageNumber 則追蹤畫面上顯示的內容;基於巡覽的目的,您需要的是檢視屬性,而不是文件屬性

一個標記為「同步頁面」之類的核取方塊可以讓使用者進行控制;當它取消選取時,每個面板會獨立巡覽,且處理常式會立即退出;這種獨立性對於兩個文件具有不同頁數的案例,或者當使用者想要在不同頁面開始的翻譯中尋找對等段落時非常重要;始終強迫同步會使該工具比簡單的雙視窗桌面配置更難使用

需要注意的一點:在同步處理常式中以程式化方式設定 PdfView.PageNumber 本身會觸發該檢視의 變更事件;請使用一個布林旗標來防止無限遞迴,在指派前將其設定,並在指派後立即清除;該旗標是針對每個表單的,而不是針對每個檢視的,因為所有三個檢視都共用同一個處理常式

每個面板獨立縮放

每個 TPdfView 都帶有自己的 Zoom 屬性,這是一個百分比的 Double,其中 Zoom := 100 代表實際大小 (100%);設定它會覆寫任何作用中的 FitMode;對於活動面板上的符合寬度按鈕,從 PdfView.PageWidthZoom[PdfView.PageNumber] 讀取符合縮放比例並進行指派;對於符合頁面,請使用 PageZoom[PageNumber];這兩個都是以 1 為基準的頁碼為索引的陣列屬性,因此在存取它們之前請先防止頁碼為零的情況

當您將當前頁面匯出為影像時,請從檢視器讀取旋轉,但請在 TPdf 元件上呼叫 RenderPage,而不是在檢視器上;TPdf.RenderPage 的點陣圖形式接受明確的像素尺寸,加上 TRotation 值和 TRenderOptions 集合;函式變體會傳回一個由呼叫者擁有的 TBitmap,您在儲存後需要自行釋放它:

procedure TFormMain.SaveActiveViewAsImage;
var
  Pdf: TPdf;
  Bmp: TBitmap;
  Jpeg: TJpegImage;
begin
  if not Assigned(FActivePdfView) or not FActivePdfView.Active then
    Exit;

  Pdf := FActivePdfView.Pdf;
  Pdf.PageNumber := FActivePdfView.PageNumber;

  Bmp := Pdf.RenderPage(
    0, 0,
    Round(Pdf.PageWidth * 2),
    Round(Pdf.PageHeight * 2),
    FActivePdfView.Rotation, [], clWhite);
  try
    if SavePictureDialog.Execute then
    begin
      Jpeg := TJpegImage.Create;
      try
        Jpeg.Assign(Bmp);
        Jpeg.CompressionQuality := 90;
        Jpeg.SaveToFile(SavePictureDialog.FileName);
      finally
        Jpeg.Free;
      end;
    end;
  finally
    Bmp.Free;
  end;
end;

寬度和高度上的 2 倍乘數可以為具有精細文字的文件提供更清晰的輸出;圍繞點陣圖釋放的 try/finally 是必須的,即使 TSaveDialog 取消也仍然會執行 finally 區塊,不論使用者執行了什麼操作,您都希望釋放點陣圖

DLL 需求

PDFium 元件封裝了原生 pdfium 函式庫;32 位元的主機程序需要 pdfium32.dll,64 位元的主機程序則需要 pdfium64.dll;帶有 V8 JavaScript 引擎的變體會加上 v8 後綴,大小約為 23-27 MB,而標準版建置則為 5-6 MB;對於停用表單填寫 (Pdf.FormFill := False) 的比較檢視器,標準的非 V8 建置即已足夠,且能保持散佈體積更小

將 DLL 放在與可執行檔相同的目錄中,或放在系統 PATH 的任何目錄中;當第一個 TPdf 被啟用時,元件會視需求載入它,因此缺少的 DLL 會在該時間點顯現,而不是在應用程式啟動時;如果您出貨安裝程式,最可靠的方法是在安裝期間將 DLL 複製到應用程式資料夾中,而不是依賴系統管理員稍後可能會清理的系統目錄

V8 建置主要在您需要與 PDF JavaScript 動作進行互動時非常有用,例如觸發計算欄位或提交處理常式;被動的比較檢視器沒有理由執行 JavaScript,在 Active := True 之前設定 Pdf.FormFill := False 可以完全跳過表單填寫環境,這也代表即使使用標準建置也不會初始化 JS 引擎;不論您出貨哪種 DLL 變體,這都是唯讀檢視器正確的預設值

有關 PDFium 元件及其完整 API 的更多詳細資訊,請造訪 Delphi PDFium 元件 產品頁面