技术文章

Delphi 中的 HotPDF 自定义 PDF 查看器:MVC 架构

HotPDF 将 Delphi PDF 查看器拆分为两部分:THPDFViewerModel 是一个负责缩放、旋转、搜索、高亮和导航状态的普通类,不依赖窗口句柄;THPDFViewer 则是一个基于 TScrollBox 的控件,负责将这些状态转换为像素。正是这种拆分,让查看器逻辑可以运行并接受测试,而且完全不需要创建窗体

大多数自定义查看器控件并不是这样设计的。缩放级别保存在控件的私有字段中,页面导航在按钮的 OnClick 处理程序中限制边界,而要确认 Ctrl+滚轮是否遵守缩放上限,唯一办法就是运行应用、单击操作并观察结果。这样的控件在普通情况下运行良好,但一旦需要回归测试套件或第二个宿主——打印预览对话框、缩略图栏、完全没有可见窗口的批量审阅器——问题就会出现:所需状态被焊接在一个坚持必须先拥有真实句柄才会工作的 TWinControl

PDF 查看器控件为什么需要 MVC 拆分

PDF 查看器之所以需要这种拆分,是因为它的状态和呈现会因不同原因、以不同速率发生变化。页面索引、缩放、视图旋转、搜索命中和高亮区域属于业务状态:它们可以在没有屏幕上任何像素的情况下计算、验证和序列化。绘制位图、捕获鼠标以及绘制框选矩形则属于呈现职责,只有控件存在时才有意义。HotPDF 将前一组放在完全没有 VCL 窗口祖先的 THPDFViewerModel 中,将后一组放在拥有模型实例并对其作出反应的 THPDFViewer 中。这更接近 Model-View 对,而不是教科书式的三层 MVC,因为没有独立的 Controller 类,THPDFViewer 本身会将原始键盘和鼠标事件转换为模型调用。比名称更重要的是依赖方向:THPDFViewerModel 不需要 Handle、消息循环或可见桌面,这正是 HotPDF 自己的测试套件可以通过 DUnitX 驱动翻页、缩放限制、键盘命令和坐标往返,而无需打开窗口的原因

uses
  DUnitX.TestFramework,
  HPDFDoc, HPDFViewerModel;

type
  [TestFixture]
  TViewerModelTests = class
  public
    [Test]
    procedure ZoomInStopsAtTheTopPresetLevel;
  end;

procedure TViewerModelTests.ZoomInStopsAtTheTopPresetLevel;
var
  Doc: THotPDF;
  Model: THPDFViewerModel;
begin
  Doc := THotPDF.Create(nil);
  Model := THPDFViewerModel.Create;
  try
    Doc.LoadFromFile('sample.pdf');
    Model.Document := Doc;
    Model.Zoom := 64.0;          // top of the preset table (6400%)
    Model.ZoomIn;                // already at the ceiling
    Assert.AreEqual(64.0, Model.Zoom, 0.0001);
  finally
    Model.Free;
    Doc.Free;
  end;
end;

THPDFViewerModel 实际负责什么

THPDFViewerModel 负责查看器回答当前屏幕上应该显示什么所需的一切,但不负责如何绘制。PageIndexPageNumberPageCount 跟踪位置;ZoomZoomModevzmActualSizevzmFitPagevzmFitWidthvzmCustom)跟踪比例;ViewRotation 跟踪非破坏性的屏幕旋转,不会触碰页面自身的 /Rotate 条目。导航方法——FirstPagePriorPageNextPageLastPage——和缩放方法——ZoomInZoomOut,在从 5% 到 6400% 的十九个固定预设级别表中移动——也位于这里,同时还有用于文本搜索的 FindAll/FindNext/FindPrevious,以及用于持久页面注释的 AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions,调用方可以在多次渲染之间保留这些注释。模型同时负责输出和输入:CreateCurrentPageSnapshotCreateCurrentPageMetafile 导出当前屏幕上的确切页面,PrintCurrentView 则将相同的当前视图——当前页面、当前缩放推导出的 DPI、当前旋转——发送到 TPrinter。这是一种范围更窄、限定于当前视图的作业,不同于 HotPDF 的 TPrinter 打印流程中介绍的整篇文档打印管线。每个重要的变更也都会触发对应事件——OnPageChangeOnZoomChangeOnSearchChangeOnHighlightChangeOnViewRotationChange——因此订阅者无需轮询即可获知变化

THPDFViewer 如何知道何时重绘

THPDFViewer 通过订阅模型而不是猜测来知道何时重绘。THPDFViewer 的构造函数会创建一个私有的 THPDFViewerModel,然后将它的每一个通知事件——OnBeginUpdateOnEndUpdateOnHighlightChangeOnPageChangeOnSearchChangeOnViewRotationChangeOnZoomChange——连接到对应的私有处理程序。每个处理程序的工作都很小:调用 RefreshDocument,该方法通过 HotPDF 页面位图渲染内部机制中介绍的同一缓存页面渲染器,将当前页面实际栅格化,然后在其上合成高亮框和搜索命中,并应用当前视图旋转。PageIndexZoomZoomModeViewRotation 等发布属性都是轻量转发器——读取器读取 FModel.PageIndex,写入器写入 FModel.PageIndex——所以无论从对象检查器还是代码中看,控件都仿佛直接持有状态,尽管状态实际只存在于 THPDFViewerModel 中。调用方也不局限于被转发的属性:THPDFViewer 通过只读的 Model: THPDFViewerModel 属性公开模型本身,因此需要 FindFormFieldAtPrefetchCurrentPageSnapshots 的代码可以绕过包装器直接调用模型,而这两个成员都没有由控件再次公开

procedure THPDFViewer.RefreshDocument;
var
  Bitmap: TBitmap;
  DPI: Integer;
begin
  // simplified: the real method also resolves fit-mode DPI
  // and composites highlight and search-hit rectangles first
  if (FModel.Document = nil) or (FModel.PageIndex < 0) then Exit;
  DPI := Round(96 * FModel.Zoom);
  Bitmap := FModel.Document.RenderLoadedPageToBitmapCached(FModel.PageIndex, DPI);
  try
    FModel.ApplyViewRotation(Bitmap);
    FImage.Picture.Bitmap.Assign(Bitmap);
  finally
    Bitmap.Free;
  end;
end;

BeginUpdate 和 EndUpdate:停止重绘风暴

BeginUpdate 和 EndUpdate 存在的原因是,一次逻辑变更通常会同时触及多个状态部分,如果每一部分都重绘一次,就会造成浪费并产生视觉噪声。替换已加载文档就是最清晰的例子:赋值给 THPDFViewerModel.Document 会重置视图旋转、清除搜索命中、清除高亮区域并跳转到第一页,而这些步骤通常各自触发一个变更事件。THPDFViewerModel 使用 BeginUpdate/EndUpdate 将这一序列包裹起来,这是一对引用计数方法:嵌套调用只会在进入最外层调用时触发 OnBeginUpdate,并在返回到最外层调用之前的状态时触发 OnEndUpdate。THPDFViewer 也在自身维护相同的深度,并在计数大于零时跳过每个细粒度事件触发的 RefreshDocument,然后在批处理结束时恰好重绘一次。细粒度事件仍会在批处理期间触发,因此只关心 OnSearchChange 的订阅者仍然可以收到通知;被合并的只是控件自身的重绘,从四次调用压缩为一次调用

框选高亮如何将鼠标拖动映射回 PDF 坐标

框选高亮通过一对专门为这种往返设计的模型方法,将鼠标拖动映射回 PDF 坐标:PagePointToViewViewPointToPage。两者都接收页面索引、DPI 和点,并分两个阶段解析变换——首先处理页面自身的 /Rotate 条目及其左下角 PDF 原点,然后处理视图独立的非破坏性 ViewRotation 及查看器的左上角设备原点——这样反向过程就能严格按相反顺序撤销这两个阶段,并在页面旋转和视图旋转的全部十六种组合下正确完成往返。用户在 vimHighlight 交互模式下拖动矩形并释放鼠标后,THPDFViewer 会调用 ViewPointToPage,将两个设备点转换为页面空间中的 THPDFRectangle,再交给 Model.AddHighlightRegion。如果要构建类似功能,还应注意一个细节:鼠标捕获属于继承自 TScrollBox 的查看器,而不属于绘制位图的子 TImage,因为 TControl.MouseCapture 受保护,只有父控件可以获取它。因此,即使拖动在鼠标按键释放前离开图像边界,过程仍会通过查看器自身重写的 MouseMove/MouseUp 解析,而不会被子控件静默丢弃

var
  ViewPt, PagePt: THPDFViewerPoint;
  Rect: THPDFRectangle;
begin
  ViewPt.X := 240;   // device pixels inside the rendered image
  ViewPt.Y := 96;
  if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
     RenderedDPI) then                 // DPI you last rendered at
  begin
    Rect.Left := PagePt.X - 40;  Rect.Bottom := PagePt.Y - 10;
    Rect.Right := PagePt.X + 40; Rect.Top := PagePt.Y + 10;
    Model.AddHighlightRegion(Model.PageIndex, Rect);
  end;
end;

除了绿色测试套件之外,这种拆分还能带来什么

收益并不局限于在没有桌面会话的 CI 作业中通过测试。由于 THPDFViewer 转发给 THPDFViewerModel,而不是复制其中的逻辑,HotPDF 可以增加第三个使用者——THPDFViewerAction 以及 THPDFZoomInActionTHPDFFindNextAction 等具体子类——将导航、缩放、搜索和旋转接入标准 Delphi TActionList,这样工具栏按钮或菜单项就能以声明式方式驱动查看器,并根据当前是否解析出查看器作为操作目标来自动启用自身。这一层完全不需要了解位图或 GDI;它调用 Viewer.NextPageViewer.Model.FindNext,现有事件链会负责重绘。由于 THPDFViewerModel 中没有任何内容引用 TScrollBoxTImage 或窗口句柄,底层状态机也没有焊接在这个控件上——相同的模型可以位于另一种渲染表面之后,而无需改动任何导航、缩放或搜索逻辑

渲染缓存在哪些地方有帮助,哪些地方没有帮助

THPDFViewerModel 的渲染缓存可以在已加载文档内发挥作用,但不会改变首次加载该文档的成本。CreatePageSnapshotCreateCurrentPageSnapshot 以及 PrefetchPageSnapshots/PrefetchCurrentPageSnapshots 等预取方法,都会通过按页面和 DPI 建立键的同一缓存渲染器,因此以相同缩放级别返回已经查看过的页面时,命中缓存而不是重新渲染;预取相邻页面的小范围区域,也能让读者逐页向前翻阅这一常见场景更加顺畅。不过,这些机制都不会降低初始 LoadFromFile 调用的成本,而设计为打开用户拖入的任意文件的查看器,最终会遇到足够大的文件,使该调用真正成为瓶颈。若要了解完整加载的分层句柄式替代方案,请参阅Direct File API 处理大型 PDF 的配套文章

本文介绍的 Model 和 View 类,是 Delphi 和 C++Builder 使用的 HotPDF 组件已加载文档表面的另外两个组成部分,既可以由窗体驱动,也可以由 TActionList 驱动,甚至完全不依赖这两者