技術文章

在 Delphi 中使用 PDFium Component 建立連續捲動的 PDF 檢視器

以舒適的閱讀縮放比例轉譯的單張 A4 頁面大約需要數百萬位元組 of 32 位元點陣圖;若乘以一份 400 頁的合約,這項計算就不再只是抽象概念:預先轉譯每一頁意味著您向 Windows 要求了遠超過 1 GB 的點陣圖,而使用者一次只會看一個螢幕的畫面;應用程式若非在 32 位元版本中耗盡位址空間,就是會在 GPU 和頁面解析器處理尚未被捲動到的頁面時凍結最初的幾秒鐘;連續捲動閱讀器必須感覺像是一條長長的頁面帶,但它實際上無法同時將所有頁面保留在記憶體中

這種拉鋸就是整個問題所在;PDFium Component 在 TPdfView 內部解決了這個問題,因此大部分的工作是選擇正確的顯示模式並了解該元件代表您所做的工作;它沒有為您處理的部分——為閱讀流程調整頁面大小並保持快速捲動時的響應能力——正是需要少量程式碼發揮作用的地方;如果您仍在組合周邊介面(工具列、縮圖、搜尋方塊),功能豐富的檢視器逐步指南涵蓋了該領域;這裡的主題是捲動本身

版面配置是顯示模式,而非點陣圖面板

來自 VCL 表單工作的直覺是尋找捲動方塊並在其中堆疊影像控制項,每頁一個;請抗拒這種直覺;該設計強迫您同時掌握頁面定位、捲動數學和記憶體問題,而且您會把每一個都重新設計得很糟糕;TPdfView 已經將文件模型化為連續的頁面序列,並透過其 DisplayMode 屬性公開版面配置

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

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

這就是整個連續捲動的設定;dmSingleContinuous 將頁面排列在單一垂直欄位中,頁面之間的間距由內部處理,且檢視畫面將該欄位作為單一介面進行捲動;不需要連接每頁的控制項,也不需要為一般導覽撰寫捲動處理常式;請注意指派後對 Pdf.Active 的檢查:開啟文件絕不會引發異常,因此損壞或受密碼保護的檔案會使 Active 保持為 False 且沒有可擷取的異常,而跳過此檢查的檢視器會轉譯空白面板並歸咎於自身

相同的屬性支援雙頁模式;dmTwoPageContinuous 將頁面並排排列,每行兩頁,以滿足某些文件需要的書籍風格閱讀;dmTwoPageContinuousWithCover 執行相同的操作,但允許第一頁單獨作為封面,以便其餘的雙頁版面落在自然的奇偶邊界上;這三種模式都能連續捲動;在它們之間切換只需一次指派,這使得稍後新增顯示模式下拉式選單變得非常簡單

只有可見頁面才會被點陣化

這能擴充到 400 頁檔案的原因在於該欄位是虛擬的;TPdfView 從文件的頁面樹中知道每個頁面的高度,因此它可以計算總捲動範圍和每個頁面的位置,而無需進行任何點陣化;點陣化是將頁面的內容資料流轉換為像素的昂貴步驟,它僅發生在目前與視埠相交的頁面上,外加一個微小的邊緣,以便在頁面捲動到視野中時做好準備;當您向下捲動時,進入視埠的頁面會被轉譯,而離開視埠的頁面則會釋放其點陣圖;記憶體保持與螢幕上顯示的內容成正比,而不是與文件長度成正比

這值得深入理解,因為它改變了您思考成本的方式;開啟一份 400 頁的文件非常便宜:它只解析結構而不解析內容;其開銷是按頁計算的,並且是在頁面被捲動到附近時才延遲支付;在開啟時感覺即時且在捲動時感覺順暢的檢視器在整體上並沒有做更少的工作,它只是將工作分散到使用者的實際閱讀路徑上,並丟棄落後的內容;實際的結果是,您幾乎永遠不想在使用者之前強制轉譯頁面;讓檢視畫面決定什麼是可見的

調整頁面大小以適應寬度,然後不要變更縮放比例

閱讀欄位需要將頁面大小調整為面板寬度,而不是固定在絕對縮放比例;FitMode 可以做到這一點,並在視窗調整大小時持續執行

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

使用 pfmFitWidth 時,元件會在檢視畫面調整大小時重新計算縮放比例,因此該欄位始終填滿可用寬度,而頁面高度(以及捲動範圍)也會隨之調整;有一個陷阱需要注意:直接指派 Zoom 會將 FitMode 重設回 pfmNone;這是刻意設計的,因為手動縮放和自動調整是矛盾的意圖,但這意謂著程式碼中某處不小心的 PdfView.Zoom := 1.0 會悄悄關閉寬度適應,且下一次調整大小時將停止重新排列版面;如果您同時提供縮放控制項 and 調整大小的按鈕,請將它們視為模式切換:設定其中一個會清除另一個,由您決定哪一個勝出

對於自然閱讀的絕對縮放控制項,檢視畫面會將最適縮放比例公開為您可以套用或顯示的值:PageWidthZoom[PageNumber] 傳回適合該頁面寬度的縮放比例,而相符的 PageZoom 則適合整個頁面;讀取這些值是您填入「適合寬度」/「適合頁面」功能表的方法,而無需硬編碼在橫向或超大頁面上會出錯的 magic percentages

利用漸進式轉譯保持快速捲動的響應能力

預設的轉譯路徑在傳回之前會將頁面繪製完成;對於單一頁面,這沒有問題;但在快速捲動瀏覽密集的文件時則不然:飛過眼前的每一頁都會啟動完整的點陣化,如果使用者捲動的速度快於頁面轉譯的速度,這些轉譯工作就會堆積,面板也會發生遲滯,因為正在為完成時已經離開螢幕的頁面進行工作;解決方法是使轉譯可取消,並在使用者移開的瞬間放棄它

RenderPageProgressive 以區塊為單位進行轉譯,並在每個區塊邊界檢查取消權杖,因此可以捨棄剛剛捲動開的頁面正在進行的轉譯,而不需要執行到結束

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

關鍵在於傳回值;prsDone 表示點陣圖已完全繪製並值得傳送到螢幕;prsCancelled 表示較新的捲動位置取代了此頁面,因此您將局部結果丟棄而不是顯示它;prsFailed 是該頁面上的真實錯誤;取消是在區塊邊界處輪詢,而不是搶佔式的,因此在呼叫 Cancel 到轉譯實際停止之間預計會有數十毫秒的延遲;這仍然比讓陳舊的整頁轉譯阻塞佇列要便宜得多;將 nil 作為權杖傳遞會直接轉譯完成,這對於像列印預覽這樣沒有取消對象的一次性轉譯是正確的選擇

當您改為呼叫 RenderPage 的函式形式(傳回全新 TBitmap 的形式)時,請記住呼叫者擁有它並必須 Free 它;在為每頁配置點陣圖的捲動迴圈中,忘記這一點會導致隨使用者瀏覽的每個頁面而增長的洩漏,這正是連續設計本應避免的無限記憶體失敗;盡可能轉譯到重複使用的點陣圖中

您的收穫

連續捲動閱讀器主要由元件來提供;您選擇 dmSingleContinuous 作為版面配置,設定 pfmFitWidth 以便欄位隨視窗重新排列,並檢查 Pdf.Active 以便損壞的檔案發出錯誤訊息;唯一值得您自己撰寫的部分是可取消的轉譯,因為閱讀器的優劣取決於當有人將捲動軸拖曳到長文件的底部時,面板是否能跟上;除此之外,跨頁面的文字選取、搜尋醒目提示、書籤樹等,都是建立在該捲動介面之上而不是內部的介面工作

此處顯示的 TPdfViewDisplayModeRenderPageProgressive API 是適用於 Delphi 和 Lazarus 的 PDFium Component 的一部分