技术文章

在 Delphi 中使用 PDFium QuadPoints 实现文本标记注释

PDFium Component 通过 TPdf.CreateAnnotation 创建文本标记注释,包括高亮、下划线、删除线和波浪线:您在 TPdfAnnotation 记录上设置 HasAttachmentPoints := True 并填充其 AttachmentPoints 四边形,组件就会写入 ISO 32000-1 §12.5.6.10 中定义的 QuadPoints 项。这就是全部的 API 表面。而本文存在的原因在于底层发生的事情,因为原始的 PDFium 调用链存在一个故障模式,会产生工具包中最无用的症状:在每次对刚创建的注释调用 FPDFAnnot_SetAttachmentPoints 时都会返回 false,既没有错误代码,也没有任何提示。这是关于读取和审查现有注释的文章在创建端的配套文章,该文章从另一个方向梳理了相同的结构

调试的场景总是相同的。您创建了一个高亮注释,调用索引为 0 的 attachment-points 设置器,函数返回 false,于是您开始怀疑自己的坐标。您转置点、翻转 Y 轴、将页面空间与设备空间进行交换。这一切都无济于事,因为坐标从来都不是问题所在。问题在于 C API 的索引语义,一旦您看清了它们,修复只需要两行代码

QuadPoints 在 ISO 32000-1 中的含义

QuadPoints 是一个包含 8×n 个数字的数组,用于描述 n 个四边形,ISO 32000-1 §12.5.6.10 要求在每个文本标记注释上都使用它:每个四边形标记一个单词或一组连续的单词,高亮、下划线或删除线应用于其上。注释的 Rect 项仍然存在,但对于标记子类型,它仅用于限制区域边界;四边形才是渲染器实际绘制的内容。之所以使用四边形而不是矩形,是因为文本可能会被旋转或倾斜,因此四个角存储为四个独立的点:x1 y1 x2 y2 x3 y3 x4 y4

这四个点的顺序是规范与已安装基群分道扬镳的地方。规范文本将这些点描述为逆时针追踪四边形,但 Adobe 自身的渲染器一直将其解释为 Z 字形图案:先是顶边从左到右,然后是底边从左到右。因为每个作者都针对 Acrobat 进行了测试,实际上每个渲染器(包括 PDFium 在内)都遵循 Z 字形图案,而遵循规范字面表述的文件在某些查看器中会渲染为塌陷或扭曲的高亮。PDFium 的 FS_QUADPOINTSF 结构精确地编码了这一约定:在 Y 向上增长的页面坐标中,(x1,y1) 是左上角,(x2,y2) 是右上角,(x3,y3) 是左下角,(x4,y4) 是右下角。遵循这一顺序即可;渲染器在很多事情上都很宽容,但混乱的四边形绝不在此列

为什么 FPDFAnnot_SetAttachmentPoints 会返回 false?

FPDFAnnot_SetAttachmentPoints 在新的注释上失败,因为它的约定是 替换 给定索引处的四边形,而刚创建的注释具有零个要替换的四边形。其函数签名接受注释句柄、quad_index 和点;索引 0 并不意味着“第一个槽位,在需要时创建它”,而是意味着“现有的 0 号四边形”,当 FPDFAnnot_CountAttachmentPoints 报告为 0 时,说明不存在这样的四边形,调用就会返回 false。用于创建槽位的函数是 FPDFAnnot_AppendAttachmentPoints。通过 FPDFPage_CreateAnnot 创建的每个注释都是从计数零开始的,因此创建路径必须先调用 Append,且只有随后的更新才能调用 Set

这影响了 PDFium Component 本身。在 v1.79.0 之前,由 CreateAnnotationSetAnnotation 共享的内部例程硬编码了 FPDFAnnot_SetAttachmentPoints(Annotation, 0, ...),这对于更新现有的标记注释是正确的,但注定会在新的注释上失败,表现为带有 'Cannot set attachment points' 消息的 EPdfException。在 v1.79.1 中发布的修复根据计数进行了分支

// 在组件的注释写入器内部 (v1.79.1+):
// 新的注释尚无四边形槽位,因此由 Append 创建
// 第一个;Set 仅替换已存在的槽位
if FPDFAnnot_CountAttachmentPoints(Annotation) = 0 then
  Check(FPDFAnnot_AppendAttachmentPoints(Annotation, QuadPoints) <> 0,
    'Cannot set attachment points')
else
  Check(FPDFAnnot_SetAttachmentPoints(Annotation, 0, QuadPoints) <> 0,
    'Cannot set attachment points');

如果您直接调用导出的 C 函数,也适用相同的模式(由于所有 FPDFAnnot_* 入口点都在 PDFium.pas 中公开,组件允许您这样做)。每当您持有 FPDF_ANNOTATION 句柄并想写入四边形时,先询问 FPDFAnnot_CountAttachmentPoints 并据此进行路由。如果您正在搜索“FPDFAnnot_SetAttachmentPoints returns false”,这个先计数后追加(count-then-append)的分支几乎就是您的答案

使用 TPdf.CreateAnnotation 创建高亮

随着组件为您进行 Append-versus-Set 路由,创建高亮就简化为了填充一个记录。下面的示例创建了一个 A4页面,并在一个 200×20 磅的区域上放置了一个半透明的黄色高亮;请注意,四边形遵循上述的 Z 字形顺序,并且 Rectangle 被设置为包围该四边形,这能使针对 Rect 进行碰撞测试的查看器表现得合乎情理

var
  Pdf: TPdf;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);

    FillChar(A, SizeOf(A), 0);
    A.Subtype := anHighlight;
    A.HasColor := True;
    A.Color := clYellow;
    A.ColorAlpha := $80;                     // 50% 不透明度
    A.HasAttachmentPoints := True;
    A.AttachmentPoints[1].X := 50;  A.AttachmentPoints[1].Y := 700; // 左上
    A.AttachmentPoints[2].X := 250; A.AttachmentPoints[2].Y := 700; // 右上
    A.AttachmentPoints[3].X := 50;  A.AttachmentPoints[3].Y := 680; // 左下
    A.AttachmentPoints[4].X := 250; A.AttachmentPoints[4].Y := 680; // 右下
    A.Rectangle.Left := 50;  A.Rectangle.Top := 700;
    A.Rectangle.Right := 250; A.Rectangle.Bottom := 680;
    A.ContentsText := 'Highlighted region';
    Pdf.CreateAnnotation(A);

    Pdf.SaveAs('highlighted.pdf');
  finally
    Pdf.Free;
  end;
end;

切换子类型只需修改一行代码。anUnderlineanStrikeoutanSquiggly 采用完全相同的记录结构、四边形和所有参数,因为 ISO 32000-1 将这四者视为相同的注释族,仅通过四边形区域的装饰方式来进行区分。不属于文本标记的子类型,例如 anSquareanCircleanText,仅根据 Rectangle 进行定位;对于这些子类型,请将 HasAttachmentPoints 保持为 False,这样四边形机制就永远不会运行

为什么 AttachmentPoints[0] 能在 Delphi 中编译却在 FPC 中失败?

TQuadrilateralPoint 被声明为 array [1..4] of TPdfPoint(一个基于 1 的数组),这会让习惯于基于 0 索引的人踩坑。写下 A.AttachmentPoints[0]时,Delphi 的 dcc32 会在没有任何申诉的情况下编译它,因为范围检查默认是关闭的;在运行时,该表达式会静默读取或写入数组前方的内存,在 TPdfAnnotation 记录中这恰好是相邻的字段。您的高亮会得到一个垃圾边角,或者相邻字段被损坏,且不会引发任何异常。Free Pascal 在向 Lazarus 移植期间抓住了我们演示源中的这处完全相同的 bug:fpc 在常量索引上执行编译时范围检查,并直接拒绝了 AttachmentPoints[0..3],这就是差一错误(off-by-one)和 Set-versus-Append 库 bug 一起被发掘出来的原因

由此养成了两个习惯。将四边形索引为 1 至 4(与上述代码中的角顺序匹配),并在信任注释代码之前,至少编译一次启用了范围检查的代码(Delphi 中的 {$R+} 或任何 fpc 构建)。默认的 dcc32 构建通过并不能证明索引是正确的;它仅证明没有在恰好存在于那里的内存上崩溃而已

从真实文本获取四边形坐标

对于演示,硬编码的矩形没有问题,但在生产环境下,高亮要追踪实际的字形,坐标应该来自于 PDFium 的文本页面几何结构,而不是靠猜测。在我们的使用 PDFium Component 提取文本指南中介绍的例程为您提供了与四边形所用相同页面坐标空间中的每个字符的包围盒,因此搜索命中可以直接转换为角点:第一个字符的左侧、最后一个字符的右侧、以及根据行范围决定的顶部和底部。如果您是自行生成文本且需要在其存在之前知道行会落在何处,文本测量和自动换行文章涵盖了提前计算这些范围的内容

一个诚实的限制:TPdfAnnotation 记录仅携带单个 TQuadrilateralPoint,因此一次 CreateAnnotation 调用仅写入一个四边形。跨越三行的选择区域需要三个四边形(每行一个,符合 §12.5.6.10),您有两种方法可以达到这一目的。简单的方法是每行一个注释,这在任何地方都能正确渲染并保留在组件级的 API。紧凑的方法是让一个注释携带三个四边形,这意味着通过组件创建注释,然后针对第二个和第三个四边形自行调用导出的 FPDFAnnot_AppendAttachmentPoints,这之所以有效正是因为 Append 创建了槽位而不是替换它们。不要尝试通过重复的 SetAttachmentPoints 调用来达成多四边形;超出当前计数的每个索引都只会返回 false,这与在新注释上索引 0 失败的原因是一样的

写入之后,要在真实的查看器中进行验证,而不是仅信任返回码:在 Acrobat 或任何基于 PDFium 的查看器中打开文件,并确认标记落在文本上、具有预期的不透明度,并能在保存和重新加载的双向回转中幸存。此处展示的注释类型、四边形处理和计数感知型写入器都是用于 Delphi、C++Builder 和 Lazarus 的标准 PDFium Component 的一部分;产品页面携带了完整的注释 API 参考以及库的其余部分