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 负责查看器回答当前屏幕上应该显示什么所需的一切,但不负责如何绘制。PageIndex、PageNumber 和 PageCount 跟踪位置;Zoom 和 ZoomMode(vzmActualSize、vzmFitPage、vzmFitWidth、vzmCustom)跟踪比例;ViewRotation 跟踪非破坏性的屏幕旋转,不会触碰页面自身的 /Rotate 条目。导航方法——FirstPage、PriorPage、NextPage、LastPage——和缩放方法——ZoomIn、ZoomOut,在从 5% 到 6400% 的十九个固定预设级别表中移动——也位于这里,同时还有用于文本搜索的 FindAll/FindNext/FindPrevious,以及用于持久页面注释的 AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions,调用方可以在多次渲染之间保留这些注释。模型同时负责输出和输入:CreateCurrentPageSnapshot 和 CreateCurrentPageMetafile 导出当前屏幕上的确切页面,PrintCurrentView 则将相同的当前视图——当前页面、当前缩放推导出的 DPI、当前旋转——发送到 TPrinter。这是一种范围更窄、限定于当前视图的作业,不同于 HotPDF 的 TPrinter 打印流程中介绍的整篇文档打印管线。每个重要的变更也都会触发对应事件——OnPageChange、OnZoomChange、OnSearchChange、OnHighlightChange、OnViewRotationChange——因此订阅者无需轮询即可获知变化
THPDFViewer 如何知道何时重绘
THPDFViewer 通过订阅模型而不是猜测来知道何时重绘。THPDFViewer 的构造函数会创建一个私有的 THPDFViewerModel,然后将它的每一个通知事件——OnBeginUpdate、OnEndUpdate、OnHighlightChange、OnPageChange、OnSearchChange、OnViewRotationChange、OnZoomChange——连接到对应的私有处理程序。每个处理程序的工作都很小:调用 RefreshDocument,该方法通过 HotPDF 页面位图渲染内部机制中介绍的同一缓存页面渲染器,将当前页面实际栅格化,然后在其上合成高亮框和搜索命中,并应用当前视图旋转。PageIndex、Zoom、ZoomMode 和 ViewRotation 等发布属性都是轻量转发器——读取器读取 FModel.PageIndex,写入器写入 FModel.PageIndex——所以无论从对象检查器还是代码中看,控件都仿佛直接持有状态,尽管状态实际只存在于 THPDFViewerModel 中。调用方也不局限于被转发的属性:THPDFViewer 通过只读的 Model: THPDFViewerModel 属性公开模型本身,因此需要 FindFormFieldAt 或 PrefetchCurrentPageSnapshots 的代码可以绕过包装器直接调用模型,而这两个成员都没有由控件再次公开
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 坐标:PagePointToView 和 ViewPointToPage。两者都接收页面索引、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 以及 THPDFZoomInAction 和 THPDFFindNextAction 等具体子类——将导航、缩放、搜索和旋转接入标准 Delphi TActionList,这样工具栏按钮或菜单项就能以声明式方式驱动查看器,并根据当前是否解析出查看器作为操作目标来自动启用自身。这一层完全不需要了解位图或 GDI;它调用 Viewer.NextPage 或 Viewer.Model.FindNext,现有事件链会负责重绘。由于 THPDFViewerModel 中没有任何内容引用 TScrollBox、TImage 或窗口句柄,底层状态机也没有焊接在这个控件上——相同的模型可以位于另一种渲染表面之后,而无需改动任何导航、缩放或搜索逻辑
渲染缓存在哪些地方有帮助,哪些地方没有帮助
THPDFViewerModel 的渲染缓存可以在已加载文档内发挥作用,但不会改变首次加载该文档的成本。CreatePageSnapshot、CreateCurrentPageSnapshot 以及 PrefetchPageSnapshots/PrefetchCurrentPageSnapshots 等预取方法,都会通过按页面和 DPI 建立键的同一缓存渲染器,因此以相同缩放级别返回已经查看过的页面时,命中缓存而不是重新渲染;预取相邻页面的小范围区域,也能让读者逐页向前翻阅这一常见场景更加顺畅。不过,这些机制都不会降低初始 LoadFromFile 调用的成本,而设计为打开用户拖入的任意文件的查看器,最终会遇到足够大的文件,使该调用真正成为瓶颈。若要了解完整加载的分层句柄式替代方案,请参阅Direct File API 处理大型 PDF 的配套文章
本文介绍的 Model 和 View 类,是 Delphi 和 C++Builder 使用的 HotPDF 组件已加载文档表面的另外两个组成部分,既可以由窗体驱动,也可以由 TActionList 驱动,甚至完全不依赖这两者