技术文章

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 驱动翻页、缩放限制、键盘命令和坐标往返,而无需打开窗口的原因

基于 HotPDF 的 Delphi 自定义 PDF 查看器架构图:无界面 THPDFViewerModel 供给 THPDFViewer 控件、TActionList 动作和 DUnitX 测试
HotPDF Delphi 查看器如何拆分为一个无界面状态机:控件、TActionList 动作和 DUnitX 测试夹具都汇聚到同一个 THPDFViewerModel,它从不需要句柄或消息循环
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;          // 预设表的顶端(6400%)
    Model.ZoomIn;                // 已经处于上限
    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 的代码可以绕过包装器直接调用模型,而这两个成员都没有由控件再次公开

HotPDF Delphi PDF 查看器重绘管道:用户输入变成模型调用,其变更事件先通过批处理门,然后执行一次 RefreshDocument 渲染、合成与绘制
从点击到像素:模型变更事件穿过 BeginUpdate 深度闸门,两条路径在每个逻辑变更上收敛为恰好一次渲染与绘制
procedure THPDFViewer.RefreshDocument;
var
  Bitmap: TBitmap;
  DPI: Integer;
begin
  // 简化版:真正的方法还会解析适配模式的 DPI,
  // 并先合成高亮框和搜索命中矩形
  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 解析,而不会被子控件静默丢弃

HotPDF Delphi 框选高亮的两段坐标往返:拖出的设备矩形经视图变换还原和页面变换还原进入 PDF 用户空间,覆盖全部十六种旋转组合
框选拖动经过两阶段逆变换 — 先撤销视图变换,再撤销页面变换 — 高亮矩形因此在任意旋转组合下都能准确落回 PDF 用户空间
var
  ViewPt, PagePt: THPDFViewerPoint;
  Rect: THPDFRectangle;
begin
  ViewPt.X := 240;   // 渲染图像内的设备像素
  ViewPt.Y := 96;
  if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
     RenderedDPI) then                 // 上一次渲染时使用的 DPI
  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 驱动,甚至完全不依赖这两者