技术文章

在 Delphi 中使用 PDFium 组件构建连续滚动 PDF 查看器

在舒适的阅读缩放比例下渲染的单张 A4 页面,大约为几个 MB 大小的 32 位位图;将其乘以一份 400 页的合同,算术就不再抽象了:预先渲染每一页,您向 Windows 索要的位图将远远超过 1 GB,而用户一次只会看一屏内容;该应用程序要么在 32 位构建版本上耗尽地址空间,要么在其最初的几秒钟内处于冻结状态,而 GPU 和页面解析器则在辛勤处理还没有人滚动到的页面;连续滚动的阅读器必须感觉像是一条长长的页面色带,但它实际上不能一次性将所有页面都保存在内存中

这种紧张关系就是这里的全部问题所在;PDFium 组件在 TPdfView 内部解决了它,因此大部分工作就是选择正确的显示模式,并理解组件代表您所做的工作;它不为您完成的部分 —— 为阅读流调整页面大小并保持快速滚动时的响应性 —— 正是少量代码发挥作用的地方;如果您仍在组装周边的外观(工具栏、缩略图、搜索框),功能丰富的查看器演练涵盖了这块领域,这里的主题是滚动本身

布局是显示模式,而不是位图面板

VCL 窗体工作的本能是伸手去拿一个滚动框(Scroll box)并在其内部堆叠图像控件(每页一个);抵制这种冲动;该设计迫使您同时拥有页面定位、滚动数学以及内存问题,并且您会把它们每一个都重新发明得很糟糕; 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 且没有异常可捕获,跳过该检查的查看器就会渲染一个空白面板并自我埋怨

相同的属性携带了跨页(Spread)模式; 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 会静默关闭适合宽度,并且下一次大小调整将停止重新排列;如果您同时提供缩放控件和适合页面按钮,请将它们视为模式切换:设置一个会清除另一个,并由您来决定哪个获胜

对于读起来自然的绝对缩放控件,视图将适合缩放公开为您能应用或显示的值: PageWidthZoom[PageNumber] 返回能将该页面适合宽度的缩放比例,配套的 PageZoom 则能适合整页;读取这些值是您填充“适合宽度”/“适合页面”菜单的方法,而无需硬编码那些在横排或超大页面上出错的微小百分比

通过渐进式渲染保持快速滚动时的响应性

默认的渲染路径在返回前会将页面绘制完成;对于单页来说这很好;但在轻拂滚动(Flick-scroll)密集的文档时则不然:飞速掠过的每个页面都会启动完整的光栅化,并且如果用户滚动的速度快于页面的渲染速度,这些渲染就会堆叠起来,面板会发生结巴,因为在该渲染完成时,已经在为已经不在屏幕上的页面做无用功了;修复方法是使渲染可取消,并在用户移开的瞬间废弃它

RenderPageProgressive 分块进行渲染,并在每个块边界检查取消 Token,因此刚刚滚动离开的页面的正在进行的渲染可以被丢弃,而不是一直运行到结束:

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 意味着位图已完全绘制且值得传送(Blit)到屏幕上; prsCancelled 意味着更新的滚动位置取代了该页面,因此您抛弃部分结果而不是显示它; prsFailed 是该页面上的真实错误;取消是在块边界进行轮询的,而不是抢占式的,因此在调用 Cancel 和渲染实际停止之间,预计有数十毫秒的延迟;这仍然比让陈旧的整页渲染阻塞队列要便宜得多;将 nil 作为 Token 传递会直接渲染到完成,这对于像打印预览这样不需要进行取消的单次渲染是正确的选择

当您改为调用返回全新 TBitmapRenderPage 函数形式时,请记住调用者拥有它并必须对其进行 Free;在为每页分配位图的滚动循环中,遗忘这一点是随着用户经过的页面而增长的泄漏,这正是连续滚动设计本应避免的无限制内存故障;如果可以的话,请渲染到重用的位图中

您剩下要做的工作

连续滚动的查看器大部分由组件来交付;您选择 dmSingleContinuous 进行布局,设置 pfmFitWidth 以便该列随窗口重新排列,并检查 Pdf.Active 以便坏文件大声失败;唯一值得您自己编写的部分是可取消 of 渲染,因为阅读器是根据当某人将滚动条拖动到长文档底部时面板能否跟得上来判断的;在此之外的一切(跨页面的文本选择、搜索高亮、书签树)都是位于该滚动表面之上而不是其内部的界面工作

此处显示的 TPdfViewDisplayModeRenderPageProgressive API 都是适用于 Delphi 和 Lazarus 的 PDFium Component 的一部分