技术文章

在 Delphi 中使用 TPDFlibViewer 构建交互式 PDF 查看器

在 Delphi 业务应用程序中嵌入 PDF 阅读面板,不需要编写渲染循环、滚动引擎或缩放模型;losLab PDF Library 提供了 TPDFlibViewer,这是一个 VCL 控件,只需一次调用即可打开文档,并带来文本选择、平移、矩形缩放、快照、区域高亮、全文搜索命中高亮、批注交互和打印等内置行为;本文将介绍把空白窗体转为可用查看器的五个步骤:放入控件、连接现成的 Action、添加搜索高亮、处理批注点击并进行打印

为什么使用查看器控件而不是编写渲染循环?

分层论点排在首位,因为它解释了您实际买到的是什么;将页面渲染为位图是简单的 20%,另外 80% 是包裹在它周围的一切:带有驱逐预算的页面位图缓存、跨越不同尺寸页面的滚动布局、在调整大小时重新计算的缩放模式、在任何缩放和旋转下将鼠标像素映射回页面坐标的碰撞测试(Hit-testing),以及不闪烁的重绘调度;TPDFlibViewer 拥有这一切,并将光栅化工作委托给多引擎渲染文章中描述的相同引擎,因此控件层和渲染层可以保持独立更换;您的窗体代码是在与可滚动的 Windows 控件对话,而不是与渲染器对话

如何在 Delphi 应用程序中嵌入 PDF 查看器?

TPDFlibViewer.LoadFromFile 通过控件自身的内部 TPDFlib 实例打开文档并在成功时返回 1,因此最简查看器是一个构造函数调用、一个 Parent 赋值和一次加载;如果您在应用程序的其他地方已经持有 TPDFlib 实例, AttachLibrary 会显示其选定的文档,同时所有权仍保留在您这边

uses PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  if FViewer.LoadFromFile('quarterly-report.pdf', '') = 1 then
    FViewer.ViewerMode := vmHand;  // pan by dragging; a plain click still follows links
end;

ViewerMode 属性可从五个值中选择鼠标的作用:vmSelect 是默认的文本选择工具,vmHand 通过使用抓取光标进行拖动来平移(单次点击仍能触发链接),vmZoom 是矩形缩放(放大拖动的带以填充视口),vmSnapshot 将拖动的区域捕获到剪贴板(带有 OnSnapshot 事件和 SnapshotToClipboard 开关),而 vmHighlight 绘制持久的半透明区域高亮;高亮工具的坐标值得注意:区域存储在页面用户空间中,而不是客户端像素中,因此 AddHighlightRegion 标记在缩放、视图旋转和页面导航时均能保留,而无需您这边进行任何簿记;在旋转的视图中,拖动方向甚至可以逆页面轴运行,在逆旋转映射后产生负的宽度或高度 —— 控件会为您归一化矩形

如何在 Delphi 中连接 PDF 工具栏而无需胶水代码?

PDFlibViewerActions 单元彻底消除了工具栏胶水代码;它提供了 20 个现成的 TAction 后代,涵盖了页面导航(TPDFlibViewerFirstPageTPDFlibViewerPriorPageTPDFlibViewerNextPageTPDFlibViewerLastPage)、缩放(TPDFlibViewerZoomInTPDFlibViewerZoomOutTPDFlibViewerFitWidthTPDFlibViewerFitPageTPDFlibViewerActualSize)、视图旋转(TPDFlibViewerRotateClockwiseTPDFlibViewerRotateAntiClockwise)、打印、文件打开和另存为对话框、复制所选文本,以及每个鼠标工具的一个开关 Action(TPDFlibViewerSelectModeTPDFlibViewerHandModeTPDFlibViewerMarqueeZoomModeTPDFlibViewerSnapshotModeTPDFlibViewerHighlightMode);每个 Action 都通过其发布的 Viewer 属性指向控件并微持其自身状态:在未打开文档时,每个 Action 都会禁用自身,导航 Action 追踪页面位置,复制 Action 追踪是否存在选择,而模式和适应 Action 会使其 Checked 状态与活动工具和缩放模式保持同步;在设计时,它们全部注册在 Action List 编辑器的 losLab PDF 类别中;在运行时,相同的类每个只需三行代码:

uses System.Actions, Vcl.ActnList, PDFlibViewer, PDFlibViewerActions;

procedure TMainForm.BuildToolbar;
var
  NextPage: TPDFlibViewerNextPage;
  FitWidth: TPDFlibViewerFitWidth;
  HandMode: TPDFlibViewerHandMode;
begin
  NextPage := TPDFlibViewerNextPage.Create(Self);
  NextPage.ActionList := ActionList1;
  NextPage.Viewer := FViewer;
  btnNext.Action := NextPage;

  FitWidth := TPDFlibViewerFitWidth.Create(Self);
  FitWidth.ActionList := ActionList1;
  FitWidth.Viewer := FViewer;
  btnFitWidth.Action := FitWidth;

  HandMode := TPDFlibViewerHandMode.Create(Self);
  HandMode.ActionList := ActionList1;
  HandMode.Viewer := FViewer;
  btnHand.Action := HandMode;
end;

如果您在这种模式之上构建自己的 Action 注册,需要了解一个单元范围的陷阱;RegisterActions 声明在 System.Actions 中,而不是在 Vcl.ActnList 中,如果在 uses 子句中仅有 Vcl.ActnList,您得到的编译器错误是极具误导性的:[TAction1, TAction2, ...] 类数组字面量会退化为被解析为一个集合,产生一连串 E2010 Incompatible types: 'Integer' and 'class of ...' 的错误,这看起来像是您的 Action 类中的类型不匹配;修复方法是添加单个 uses 条目,但诊断却指向了除它之外的任何地方;对于缩放工具栏,TPDFlibViewer.EffectiveZoomPercent 报告实际生效的缩放比例(包括在适应宽度和适应页面模式下计算的系数),因此放大步骤可以基于真实的当前放大倍数构建,而不是基于您分配的最后一个值

搜索命中高亮以及其后的坐标翻转

HighlightSearchHits 在整个文档中搜索查询并为每个匹配绘制一个半透明条,返回命中计数;ClearSearchHighlightsSearchHighlightCountSearchHighlightColor 属性使这一界面完整;因为命中矩形保存在页面空间中,并在每次绘制时重新投影,所以在用户滚动、缩放和旋转视图时,这些条会一直粘在文本上;如果您使用配套的 TPDFlibSearchPanel,它的 RunSearch 会通过 HighlightInView 属性(默认开启)自动在附带的查看器中绘制相同的条

procedure TMainForm.btnSearchClick(Sender: TObject);
var
  Hits: Integer;
begin
  FViewer.SearchHighlightColor := clYellow;
  Hits := FViewer.HighlightSearchHits(edtQuery.Text, False);  // case-insensitive
  StatusBar1.SimpleText := Format('%d matches', [Hits]);
end;

微妙的部分,也是较之于从原始搜索结果自行开发而更倾向于选择内置路径的原因,是坐标系统的转换;文本引擎报告匹配矩形的坐标取决于库的 Origin 设置:在 Origin 为 0 或 3 时,Y 轴自底向上运行(如同原生的 PDF 用户空间),而在 Origin 为 2 或 3 时,X 轴自右侧边缘开始测量;查看器的覆盖层工作在自顶向下、基于左侧的页面空间中,因此每个命中矩形在存储前必须进行翻转(Y = PageHeight - Bottom,在适用时 X = PageWidth - Right)—— 在默认 Origin 下直接采用报告的顶边值,高亮会被绘制在页面垂直镜像的位置上;TPDFlibViewer 在记录每个命中时会根据活动 origin 进行此转换,然后在绘制时应用正向旋转映射,这正是高亮条在任何角度下都能正确落地的原因;半透明度本身使用带有恒定源 alpha 的 Windows AlphaBlend API,因为 VCL 画布没有原生的 alpha 混合;底层的文本搜索和几何 API 在文本搜索和页面元素枚举文章中进行了深入介绍

批注点击、提示和附件导出

TPDFlibViewer 将批注视为一等交互对象;点击非链接批注会触发 OnAnnotClick(带有页码、基于 1 的批注索引和子类型字符串);AnnotAtPagePoint 以编程方式公开相同的碰撞测试,并跳过 Popup 伴随批注,因此您总是能得到用户实际看到的批注;悬停在批注上会通过 AnnotHints 开关(默认开启,且配合标准 VCL 的 ShowHint 属性工作)将批注的 Contents 文本显示为控件的提示;对于 FileAttachment 批注,SaveAnnotAttachmentToFile 将嵌入的文件写入磁盘并在成功时返回 1,这在寥寥几行代码内就完成了“点击即保存”的工作流程:

procedure TMainForm.ViewerAnnotClick(Sender: TObject; APage, AnnotIndex: Integer;
  const Subtype: WideString);
begin
  if Subtype = 'FileAttachment' then
    if FViewer.SaveAnnotAttachmentToFile(APage, AnnotIndex,
        'C:\Temp\attached-invoice.xml') = 1 then
      StatusBar1.SimpleText := 'Attachment saved';
end;

打印和您应当考虑的边界

打印不需要单独集成;PrintDoc 显示标准的 Windows 打印对话框 —— 打印机选择、页面范围、份数、逐份打印 —— 并将作业移交给库的打印引擎,而 PrintDoc(False) 会直接将整个文档发送 to 默认打印机,完全没有 UI;PrintDocRange 使用显式打印机名称、起始和结束页面、副本计数以及逐份打印标志实现完全编程化,这是同一查看器实例驱动的批量或服务器触发的打印作业的正确入口

有两个边界值得诚实说明;首先,TPDFlibViewer 是 Windows VCL 控件,查看器层在 NOVCL 构建中是被排除的,因此控制台服务和非 VCL 目标应当直接使用底层的 TPDFlib 渲染 API;其次,无头(Headless)使用虽特意支持但有界限:CaptureRegion 将任何客户端区域(页面加高亮)渲染到调用者拥有的位图中,并且可以在没有窗口句柄的未父化(Unparented)控件上工作,因为绘制路径避免了会强制创建句柄的 VCL 调用;这使得自动测试中像素级的验证非常实用,但任何真正需要消息循环的事情(例如交互式鼠标工具)仍然属于真实的 UI 会话;在性能方面,该控件通过 SetCacheLimit 保持带有可配置预算的内存页面缓存,并且可以跨会话持久化渲染过的页面;查看器磁盘页面缓存文章详细介绍了该层及其每显示器 DPI 的行为

那么这五个步骤为:放置控件并调用 LoadFromFile,从 PDFlibViewerActions 附加现成的 Action,调用 HighlightSearchHits 进行页内搜索,处理 OnAnnotClick 实现批注工作流,以及公开 PrintDoc;这里展示的每个部分均在适用于 Delphi 的标准 losLab PDF Library 中随同查看器控件、Action 类和渲染引擎一同安装