技术文章

用 PDFium Component 做 Delphi PDF 注解评审

PDF 注解是一个附在页面上的字典,而不是画在页面上的一个痕迹。ISO 32000-1 §12.5 定义了大约二十几种子类型,每一种都带着一个 /Subtype、一个页面坐标中的矩形、一组标志位,通常还有一个决定查看器实际画出什么的外观流。对一个正在评审文档的人来说,这些子类型的含义并不相同。Highlight 和 Ink 笔迹是批注;Link 是导航;Popup 是你点开便签时弹出的那个小窗口,它作为独立对象存储,由一个父项指向。回复则是完整的 Text 注解,通过一个 in-reply-to 条目引用它所回应的那条批注。所以页级的注解数组并不是评审者眼中的批注列表。它是一个扁平的口袋,里面装着批注、把它们连起来的管道,还有好几样评审者根本不会称之为批注的东西。一个把这个数组当成批注列表的面板,会跟客户手上其他每一个查看器说法不一

在 PDFium Component 上搭建注解评审工作流——它是面向 Delphi、C++Builder 和 Lazarus 的基于 PDFium 的 VCL/LCL 组件——意味着把精力集中在原始数组与人类视角之间那道缝隙引发麻烦的地方:计数、建索引、给引擎已经冻住的标记重新着色、删除而不留残影,以及添加你自己的标记

示意图:Delphi PDFium 评审面板如何把包含批注、弹窗、回复和链接的原始页面注解数组,过滤成评审者看到的那份经过整理的批注列表
页面注解数组把批注与弹窗、回复、链接和隐藏标记混在一起,所以评审面板在显示总数之前需要先定一条计数规则

为什么你的计数永远对不上 Acrobat 的批注窗格

把一份改满了的合同同时在你的查看器和 Acrobat 里打开,两边的总数很少一致。Acrobat 显示的是一份经过整理的视图:标记按回复线程分组、弹窗折进它们所属的便签、链接和表单部件被排除在外。原始数组则不加区分地把它们全装着,所以一次天真的计数会同时在某些方面偏高、在另一些方面偏低

弹窗会把总数撑大,因为每张便签都配着一个独立的 Popup 对象,两个都数就把这张便签数了两遍。回复则会把总数压小——如果你按可见标记过滤的话,因为回复是一个 Text 注解,在有人展开线程之前什么也不画,而丢掉它就丢掉了整场讨论。Hidden 和 NoView 标志位让一个注解从屏幕上消失,却不把它从数组里拿走,所以一次不看标志位的计数会把用户看不见的标记也算进去。链接注解和批注坐在同一个数组里,而它既不该进计数,也不该进列表。在写循环之前就把计数规则定下来,并且把这个决定写下来,因为"为什么你们面板显示的数字和 Acrobat 不一样"是一个评审功能挣到的第一张工单

一次性把所有东西索引好,然后再也不重新解析页面

有一条设计规则驱动着后面的一切:按作者、类型或页码过滤,绝不能重新解析页面对象。在一份标记密集的 300 页文档上,每次下拉框变化都重新解析,会让面板一卡就是好几秒。组件暴露了 AnnotationCount 和带索引的 Annotation[] 属性,两者的作用域都是当前加载的那一页,而它们交回的 TPdfAnnotation 记录带着列表视图所需要的一切:SubtypeFlagsColorRectangleContentsTextAuthorText。正确的做法是在打开文件时把每一页扫一遍,并维护你自己的扁平索引:

procedure TReviewPanel.BuildIndex;
var
  PageNo, i: Integer;
  A: TPdfAnnotation;
begin
  FItems.Clear;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    for i := 0 to Pdf.AnnotationCount - 1 do
    begin
      A := Pdf.Annotation[i];
      // 只保留与评审相关的子类型;记下页码与索引这一对,
      // 因为后续所有编辑都按它来寻址
      if A.Subtype in [anText, anHighlight, anInk] then
        FItems.Add(TReviewItem.Create(PageNo, i,
          A.AuthorText, A.ContentsText, A.Rectangle, A.Color));
    end;
  end;
end;

值得画重点的那一对是 (PageNo, i)。后续每一次改动,不论是重新着色还是删除,都靠页码加注解索引来寻址,而索引是脆弱的:删掉一个注解,会让那一页上它之后的全部重新编号。所以要打算在任何一次删除之后重建受影响页面的条目,而不是就地修补索引数字。重建的代价是一毫秒。相比之下,一个过期的索引会删掉另一位评审者的批注,这种缺陷会侵蚀人们对整个功能的信任

即便你的第一个版本只统计回复而不显示它们,线程结构也值得在索引里占一个位置。趁页面还开着的时候按父项引用把条目分好组,这样面板日后就能像 Acrobat 那样折叠一个线程。在滚动过程中懒惰地重建这份分组,会彻底废掉"只索引一次"的全部意义,因为它会重新打开你已经付过解析代价的页面。几何数据需要同样的纪律。每条记录里的 Rectangle 都是页面空间的,把它换算成视图坐标这件事应当放进一个共享的辅助函数,而不是散落在各处。当选中、命中测试和绘制各自发明自己的缩放与旋转算法时,面板就会长出坐标缺陷;把这三者都走同一条换算路径,一处高亮、它在列表里的那一行、以及它的点击目标就会始终钉在同一片墨迹上

重新着色与外观流的否决权

把一处高亮从黄色改成琥珀色听起来像一行代码的事,有时候确实是。麻烦出在 ISO 32000-1 §12.5.5。当一个注解带着 /AP 外观流时,合规的查看器画的是那份预先构建好的流,并把字典里的颜色条目当作已死的元数据。Acrobat 基本上会为它创建的一切写外观流,所以从客户那里来的注解大多已经处于这种状态,而你信心满满设下的颜色永远到不了屏幕上。重新着色是通过 Annotation[] 属性做的一次读取-修改-写回,而组件对这个冲突是诚实的:当引擎拒绝让字典颜色覆盖一份烘死的外观时,这次写入会抛出 EPdfError

示意图:Delphi PDFium 组件中重新着色的读取-修改-写回路径,一份烘死的外观流否决了字典颜色并抛出 EPdfError
当注解带着预先构建的 /AP 流时,引擎会拒绝字典颜色并抛出 EPdfError,于是面板改为给自己的叠加层着色,或者把该行标记为外观已锁定
A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // 琥珀色
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // 这个注解自带一份预渲染的 /AP 流;只改字典里的颜色
    // 无法改变查看器画出来的东西
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

每一次都要捕获那个异常,并且把它当作信息而不是失败。跳过这道防护,你的面板就会在自己的列表里欢快地显示琥珀色,而页面继续画着黄色;用户几周后把它报成"你们的查看器无视我的编辑",而你花一个下午在一份恰好没有外观流的文件上复现不出来。一旦知道外观被锁定了,你有两种诚实的回应:给你自己的选中叠加层着色而不是给注解着色,这样评审者至少能看到自己挑的那处高亮;或者把该行标记为外观已锁定,让谁也别指望这次改动能生效

删除注解而不留下残影

DeleteAnnotation 会把对象从当前页的注解树里移走,但它不碰缓存的页面位图。调用之后立刻绘制,被删掉的高亮仍然留在屏幕上,坐在一张与背后文档模型已经对不上的位图里。修法是把重新渲染当作删除的一部分,而不是一个调用方可能忘掉的步骤:

示意图:Delphi PDFium 三步删除循环——移除注解、用 reAnnotations 重新渲染页面、重建该页索引
删除只动注解树,所以面板必须用 reAnnotations 重新渲染并重建该页条目,显示和索引才重新变得诚实
Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // 失败时抛出 EPdfError
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage 把位图的所有权交给调用方
end;
RebuildPageEntries(Item.PageNo);  // Item.Index 之后的索引已经移位

那段代码里有两个细节很容易做错。reAnnotations 选项必须在,否则新的位图会丢掉剩下的每一个注解,页面看起来就像你抹掉了整套批注而不是一处标记。另外 Bmp.Free 不是可选的:函数形式的 RenderPage 重载把位图的所有权交给了调用方,所以漏掉释放,每一次删除都会泄漏一整页的位图,一个在长文档里连续工作的评审者几分钟内就能把它变成真实的内存压力

从你自己的界面添加评审标记

创建注解走的是 CreateAnnotation,它接受一个填好的 TPdfAnnotation 记录(子类型、矩形、颜色、内容、作者)并把它附到当前页上。一张便签,也就是子类型 anText,是最容易的情形:设好位置、内容和作者就完事了。人们栽跟头的地方是 Ink 注解。记录里的矩形只框住绘制范围;笔迹本身是一组组点,必须通过引擎的笔迹调用 FPDFAnnot_AddInkStroke 单独附上,喂给它 FS_POINTF 数据,这些数据是从鼠标或手写笔输入中一笔一笔采集来的。只用一个矩形而别的什么都不给就去构建一个 Ink 注解,你得到的是一团渲染成空白的空涂鸦,看起来像引擎的缺陷,实际上是一个做了一半的注解

顺带把作者归属的策略也定下来。你的界面创建的每一个标记都应当带着一致的 AuthorText,因为你下个月要做的评审者筛选器,好不好用完全取决于你今天盖在批注上的那些名字。空白或前后不一的作者字符串,事后除了重新打开每一份文件之外无法补救

把评审结果带出查看器

评审数据只有能离开查看器才算物有所值——变成项目负责人不用打开文件就能读的摘要,或者一份喂进跟踪表的 CSV。要从你已经建好的索引导出,绝不要重新解析一遍,并且挑一种稳定的方式回指每一处标记。页码配上注解的矩形能扛过数组索引扛不住的往返,因为下一次删除会悄悄给索引重新编号,而你的 CSV 就开始指向错误的批注了

一行值得保留的记录会带着页码、子类型、作者、文件里记录了创建时间戳时的那个时间戳、内容文本,以及一个由你自己而不是 PDF 提供的状态列。同一趟索引扫描在更早的时候也有用,也就是在接收阶段,当一份文档从团队外部到来、而你想在任何人评审之前知道里面有些什么。PDF 接收工作台一文走了一遍那套分流流程,而表单字段导航讲的是镜像问题:评审那些为收集数据而不是收集批注而做的文档

有一种情况数组不会告诉你

有一种失败形态值得单独标出来,因为它看起来像你代码里的缺陷,其实不是。客户报告说一页上到处都是看得见的高亮,可你的面板一条也列不出来,AnnotationCount 回来的是零。通常的解释是这些标记在上游某处被扁平化了。扁平化把注解外观烘进普通的页面内容,于是高亮成了页面图形的一部分,彻底不再作为注解对象存在。已经没有任何东西留给注解 API 去枚举、着色或删除了。当你看到画出来的标记配上零计数时,别再去枚举循环里找缺陷,去问这份文件是怎么生成的

这里用到的注解接口,从枚举、创建到重新着色、删除,以及让显示保持诚实的那些渲染选项,都随 PDFium Component 一同发布,支持 Delphi、C++Builder 和 Lazarus/FPC