审阅期间在段落周围绘制的矩形不必成为 PDF 内部的标记。HotPDF 的 THPDFViewerModel 提供了 AddHighlightRegion 方法,该方法将每个高亮保留为内存中的记录,而不是对已加载文档的修改,因此审阅者可以标记数十个页面,同时磁盘上的文件仍与原文件逐字节一致。将缩放调整到 6400%,把页面旋转 90 度,再从“适合宽度”切换到“适合页面”,同一个矩形仍会落在同一个段落上,因为坐标计算会在绘制标记的时刻通过实际渲染几何结构执行
围绕 PDF 查看器构建的审阅工具经常遇到这个问题。无论是修订界面、对生成发票进行质量检查,还是内部签核流程,都需要允许用户突出页面中的某个区域,同时不能让每个草稿标记都变成文件的永久修改,也不应仅为在用户决定标记是否成立之前显示一个彩色方框,就引入完整的注释子系统。HotPDF 通过专用高亮层解决了这个问题,该层完全位于 在 Delphi 中使用 MVC 架构构建自定义 PDF 查看器 一文所述拆分中的 Model 侧,这也使同一份高亮列表可以由单元测试驱动,而无需任何窗口句柄
HotPDF 的 AddHighlightRegion 实际存储什么
AddHighlightRegion 为每个标记准确存储三项内容:从零开始的页面索引、PDF 用户空间坐标中的 THPDFRectangle,以及一个 TColor,全部封装为 THPDFViewerHighlight 记录并存放在 THPDFViewerModel 中。调用 Viewer.HighlightRegion(PageIndex, PageRect, clYellow) 或等效的 Model.AddHighlightRegion 时,其中一条记录会追加到私有数组,并返回其索引;调用方获得的唯一句柄就是这个索引:没有单独对象,没有引用计数接口,也没有需要释放的内容。本文中的其他所有能力,包括绘制标记、缩放变化后重新映射标记以及删除标记,都是建立在这个小型记录之上
每个矩形在被接受前都会经过规范化和裁剪。若审阅者从右向左拖动,AddHighlightRegion 会交换左右边缘;若向上拖动,则交换上下边缘;随后通过 GetLoadedPageBox 获取页面的 MediaBox,并根据它裁剪结果。若矩形最终宽度为零、高度为零或完全位于页面外,方法会直接拒绝它:返回 -1,并且不会向列表添加任何内容。这个返回值并非装饰:从外部审阅文件重建的一批高亮,或页面被替换后依据过期坐标重建的高亮,如果调用方不检查该值,就可能悄然丢失条目
高亮在缩放或旋转后如何保持对齐
高亮能够保持对齐,是因为 HotPDF 将它存储在 PDF 页面空间中,并在每次重绘时将其重新投影到屏幕空间,而不是存储一个在缩放级别变化后立即失效的屏幕矩形。THPDFViewerModel.PagePointToView 及其逆向方法 ViewPointToPage 会分两个阶段完成投影:先处理页面自身的 /Rotate 条目,再处理 Viewer 独立的 ViewRotation;后者不会写回 PDF,只影响 Viewer 的显示效果。鼠标释放时撤销变换也会以相反顺序执行相同两个阶段,因此即使审阅者在旋转 270 度的页面上以高倍缩放绘制高亮,在将视图重置为“适合页面”后,高亮仍会准确落在原位置
用于投影的 DPI 与旋转同样重要。HotPDF 的 Viewer 会在每次渲染后立即将屏幕上当前位图的精确 DPI 保存到 FRenderedDPI,而 ImageMouseUp 会将同一个值传入 ViewPointToPage,因此鼠标坐标始终依据实际绘制时使用的分辨率进行转换,而不是依据当前缩放属性重新计算的分辨率。CreatePageSnapshot 及其相关方法会将 DPI 限制在 12 到 2400 的范围内,但交互式渲染路径没有这样的上限:标准缩放阶梯最高为 6400%,以默认 96 DPI 为基准时计算出的 DPI 会远超 2400,因此若在坐标映射中复用快照式限制,缩放范围顶端的每个高亮都会偏移数个像素。交互还设有两个较小的默认规则:任一轴上的拖动距离短于两个像素时会被视为单击,不产生高亮;并且在至少一个页面实际完成渲染之前不能开始高亮,因为 FRenderedDPI 的初始值为零
将交互式高亮接入审阅界面
在 THPDFViewer 控件本身上设置三个属性即可启用交互式高亮:将 InteractionMode 从默认的 vimBrowse 设置为 vimHighlight,选择一个 HighlightColor(默认值为 clYellow),并处理 OnMarqueeSelect 以获知审阅者刚刚绘制的内容。其他工作,包括捕获鼠标、在拖动时绘制点状选择矩形、将释放点转换回页面空间以及调用 AddHighlightRegion,都会在该事件触发前由控件内部完成
type
TReviewForm = class(TForm)
Viewer: THPDFViewer;
ReviewLog: TMemo;
procedure FormCreate(Sender: TObject);
private
procedure ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
PageIndex: Integer; const PageRect: THPDFRectangle;
HighlightIndex: Integer);
end;
// PdfDoc is a THotPDF already loaded elsewhere on the form
procedure TReviewForm.FormCreate(Sender: TObject);
begin
Viewer.PDFDocument := PdfDoc;
Viewer.InteractionMode := vimHighlight;
Viewer.HighlightColor := clLime;
Viewer.OnMarqueeSelect := ViewerMarqueeSelect;
end;
procedure TReviewForm.ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
PageIndex: Integer; const PageRect: THPDFRectangle; HighlightIndex: Integer);
begin
ReviewLog.Lines.Add(Format('page %d, mark #%d at (%.1f, %.1f)-(%.1f, %.1f)',
[PageIndex + 1, HighlightIndex, PageRect.Left, PageRect.Bottom,
PageRect.Right, PageRect.Top]));
end;
OnMarqueeSelect 只会在拖动确实生成高亮时触发:过小而不构成拖动的单击会立即清除选择叠加层,而完全落在页面外的拖动会到达 AddHighlightRegion,并在那里以与程序化调用相同的方式被拒绝,因此无论哪种情况,事件都保持静默。如果高亮在控件边缘似乎停止响应,有一个实现细节值得注意:鼠标捕获属于 THPDFViewer 自身(它是 TScrollBox 的后代),而不属于显示页面位图的内部 TImage,这使审阅者可以拖过渲染页面的边缘,并仍然获得干净的释放操作
通过代码添加、删除和重新读取高亮
高亮完全不必来自鼠标拖动。Viewer.HighlightRegion(PageIndex, PageRect, Color) 会转入交互式拖动在内部调用的同一个 Model.AddHighlightRegion,它公开存在,正是为了让审阅界面能够根据已有数据重建高亮:从数据库加载的评论、文本搜索结果,或从上一会话恢复的标记。由于坐标是普通的 PDF 用户空间数值,这条路径不依赖页面是否已经渲染,这一点不同于需要 FRenderedDPI 已经保存真实值的交互式拖动
var
I: Integer;
Item: TPriorComment; // your own record: PageIndex + PageRect
NewIndex: Integer;
begin
for I := 0 to PriorComments.Count - 1 do
begin
Item := TPriorComment(PriorComments[I]);
NewIndex := Viewer.HighlightRegion(Item.PageIndex, Item.PageRect, clAqua);
if NewIndex < 0 then
LogWarning('comment %d fell outside the page and was dropped', [I]);
end;
end;
删除单个高亮时,基于数组的存储方式就会显现出来。RemoveHighlightRegion 会删除一条记录,并将其后的所有记录向前移动一个位置以填补空缺,这意味着先前保存的任何索引,无论来自 OnMarqueeSelect 事件还是来自先前的枚举,在列表中位于被删除项之后的内容被移除后都不再可靠。每次添加、删除以及调用 ClearHighlightRegions 时都会触发 OnHighlightChange,但它不会提供发生变化的具体信息,因此安全做法是将它视为一个信号:使用 HighlightCount 和 TryGetHighlightRegion 从头重建审阅面板正在显示的列表,而不是就地修改缓存索引
procedure TReviewForm.ViewerHighlightChange(Sender: TObject);
var
I: Integer;
Mark: THPDFViewerHighlight;
begin
MarkList.Items.Clear;
for I := 0 to Viewer.Model.HighlightCount - 1 do
if Viewer.Model.TryGetHighlightRegion(I, Mark) then
MarkList.Items.AddObject(Format('page %d', [Mark.PageIndex + 1]),
TObject(I));
end;
何时应将标记改为真正的高亮注释
当高亮区域需要脱离当前这个 THPDFViewer 实例继续存在时,就应将它变成真正的注释。HotPDF 还提供了用于新页面的 AddHighlightAnnotation 和用于已加载文档的 AddLoadedHighlightAnnotation;尽管名称非常相似,但这是完全不同的机制:两者都会将实际的 ISO 32000-1 §12.5.6.10 文本标记注释(PDF /Subtype /Highlight)写入页面的 /Annots 数组,并用 /QuadPoints 标示精确的字形范围;文件保存后,任何符合规范的 PDF 查看器都会渲染它,而不只是 HotPDF 自身。相同的机制边界也决定标记是否能通过 XFDF 往返:使用 AddLoadedHighlightAnnotation 创建的注释是普通 PDF 对象,ExportLoadedAnnotationsToXFDF 会获取它,并以 ISO 19444-1 标记的形式交给 Acrobat 或其他审阅工具;这部分内容在 在 Delphi 中导入和导出 PDF 注释为 XFDF 一文中有介绍。而通过 AddHighlightRegion 添加的区域不会出现在该导出结果中,因为它从未写入对象图:它只在创建它的 THPDFViewerModel 存在期间有效。页面上可用的完整标记和几何注释类型,以及矩形如何定位每一种注释,详见 使用 Delphi HotPDF 组件处理 PDF 注释 一文,实际规则很简单:文档仍在讨论时让标记保持可丢弃,决定最终后再将它提交为注释
高亮层的边界
高亮层本身并不试图模拟半透明的荧光笔:RefreshDocument 会像绘制搜索命中项那样,用高亮自身的颜色在缓存页面位图上方将每个区域绘制为两像素宽的轮廓矩形,而不是在底层文本上混合填充颜色,因此经典的黄色涂抹效果必须由应用程序代码绘制,或延后交给已提升注释自身的外观流来处理。区域存在后,有一项能力值得复用,那就是 CreateCurrentPageRegionSnapshot;它使用高亮已有的同一个 THPDFRectangle,仅将该区域渲染为位图,适合为审阅评论附加一张小型预览图,而无需导出整个页面。审阅版本不必一开始就在两种机制之间做出选择:只要评论线程仍处于开放状态,就将每个新标记默认为可丢弃的 THPDFViewerHighlight 区域,待审阅者解决问题后再调用 AddLoadedHighlightAnnotation,这样在往返讨论最频繁的阶段可以保持已加载 PDF 不变。本文介绍的查看器控件属于面向 Delphi 和 C++Builder 的标准 HotPDF 组件,与上文提到的其他注释和表单 API 同属其中