技术文章

PDFium Delphi表单中控件索引与批注索引的区别

在面向Delphi、C++Builder和Lazarus、基于PDFium的VCL/LCL组件PDFium Component中,一个表单字段索引不等于一个批注索引。一个页面除了控件之外,还会携带链接、文本和墨迹批注,所以字段枚举必须按FPDFAnnot_GetSubtype过滤,并暴露一个从零开始的逻辑索引,只有在真正的原生调用那一刻,才把它映射回一个真实的批注位置

暴露这个问题的bug一旦见过就再也认不错。测试人员在一份已填写的发票表单里按Tab键,光标却消失了,因为焦点跑到了页脚的一个超链接上。更糟的情况是干脆什么都没发生:你的代码记录字段3已获得焦点,UI面板也更新了,而FORM_SetFocusedAnnot其实从头到尾都在悄悄返回false。这两种症状源自同一个设计上的失误,其中一个症状底下还藏着第二个根因

PDFium交给你的两套索引空间

PDFium在同一个页面上暴露了两套编号体系,只有当一份文档碰巧除了表单控件什么都不包含时,这两套体系才会重合。第一套是批注索引:在页面/Annots数组中的位置,这正是FPDFPage_GetAnnotCount统计的对象,也是FPDFPage_GetAnnot接受的参数(ISO 32000-1 §12.5.2)。第二套是一个应用层API理应提供的逻辑字段索引,从零开始,只遍历用户真正能够到达的交互式字段。ISO 32000-1 §12.5.6.19把控件批注定义为交互式表单字段的可视化表现形式,§12.7定义了表单本身。页面上的其他一切都是语义各不相同的其他子类型:一个链接批注有一个目的地,一个墨迹批注有一份笔画列表,一个文本批注是一张贴纸式便签。它们没有一个应该被计入字段计数,也没有一个能够接受表单焦点。然而在/Annots数组里,它们会按照生成这份文档的应用程序当初写入的顺序,和控件们交错排列在一起,而这个顺序往往和文档其他任何方面暗示的顺序都对不上

为什么按Tab键会落到一个超链接上,而不是下一个字段?

因为字段计数其实一直是批注计数。最初的实现直接把FPDFPage_GetAnnotCount的返回值当作FormFieldCount返回,而字段信息访问器、tab顺序辅助函数和焦点辅助函数,全都把这同一个整数当作控件位置来对待。在一个干干净净、只有六个控件、别无他物的AcroForm页面上,六等于六,每个测试都能通过。一旦在页脚加一个超链接、在页边加一条审阅批注,计数就会报告出八个字段,索引6和7会解析到非表单对象,而Tab键会一头扎进它们里面

在枚举这一端的修复方式,是统计子类型,而不是统计批注。逐个打开每一个批注,询问它的子类型,只保留控件,并在一个finally块中关闭句柄,因为FPDFPage_GetAnnot返回的是一个需要通过FPDFPage_CloseAnnot归还的、有归属权的句柄

function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
  Count, I: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := 0;
  if Page = nil then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);   // every annotation, not just fields
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
        Inc(Result);
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

请注意这里刻意没有做的事情。它没有向表单填写环境询问任何东西,也不需要一个表单句柄,因为子类型信息就存在批注字典里,仅凭页面本身就能读取。这在顺序安排上很关键:这个计数在你还没决定这份文档是否值得配一个表单填写环境之前就已经可用了,AcroForm JavaScript与宿主事件一文把这看作一个安全决策,而不仅仅是一个便利功能

在原生调用边界把逻辑索引映射回去

防止这两套索引空间相互泄漏的规则很简单:逻辑索引是唯一一个跨越你公开API边界的数字,它只在紧邻原生调用之前的最后一个函数里,才会被转换成批注索引。有一个映射辅助函数,被字段信息、焦点、标志设置器和tab顺序共同使用,正是它让这条规则真正能够被执行

function AnnotationIndexForField(Page: FPDF_PAGE;
  FieldIndex: Integer): Integer;
var
  Count, I, Current: Integer;
  Annot: FPDF_ANNOTATION;
begin
  Result := -1;
  if (Page = nil) or (FieldIndex < 0) then
    Exit;
  Count := FPDFPage_GetAnnotCount(Page);
  Current := 0;
  for I := 0 to Count - 1 do
  begin
    Annot := FPDFPage_GetAnnot(Page, I);
    if Annot = nil then
      Continue;
    try
      if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
      begin
        if Current = FieldIndex then
          Exit(I);        // real /Annots position: native calls only
        Inc(Current);
      end;
    finally
      FPDFPage_CloseAnnot(Annot);
    end;
  end;
end;

这个辅助函数有两个特性值得直说清楚。它是一次线性扫描,所以在一个有着几百个控件的页面上,对每个字段都朴素地跑一遍这个循环,代价是二次方数量级的批注打开操作;如果你要枚举整个页面,应该一次性遍历所有批注、边遍历边收集控件句柄,而不是对每个字段都单独调用一次这个映射函数。而且它返回的是-1,而不是抛出异常,这让调用方可以自己决定,一个过时的索引到底是一个值得抛异常的编程错误,还是一个可以忽略的竞态——比如在一次编辑删除了某个批注、而一份缓存的UI列表还在引用它的情况下

为什么FORM_SetFocusedAnnot在无界面页面上会失败?

因为PDFium拒绝把焦点给一个所属页面视图从未被标记为有效的控件。FORM_SetFocusedAnnot会在表单填写环境内部把这个批注解析到一个页面视图上,如果这个页面视图不存在,它就会返回false,且不带任何诊断信息。所以,仅仅修正索引映射,能解决Tab键落到超链接上的问题,却留下了第二个症状没有解决:你的逻辑焦点记录说的是字段3,而原生获得焦点的控件其实什么都没有,每一个建立在原生焦点之上的访问器——获得焦点的文本、获得焦点的值、选项的选中状态——都持续返回空值。页面视图是由FORM_OnAfterLoadPage创建、由FORM_OnBeforeClosePage销毁的。在一个围绕可视化控件搭建的查看器里,这两次调用是显示一个页面的过程中自然发生的一部分,这也正是为什么这个失败看起来常常像是一个"只在无界面模式下才出现"的bug:在GUI演示程序里能正常工作的同一份代码,到了批处理工具里就失败了。这个生命周期本该属于文档对象,而不是查看器,所以PDFium Component现在会在任意一个存在表单句柄的页面被加载或卸载时,都发出这两次调用。这个C函数签名是先接受页面、再接受表单句柄,手写绑定代码时很容易把顺序搞反

procedure ReportFirstField(const FileName: string);
var
  Pdf: TPdf;
  Idx: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FormFill := True;      // form-fill environment, before Active
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Pdf.PageNumber := 1;       // page load also runs FORM_OnAfterLoadPage

    Idx := Pdf.FocusNextFormField;   // logical index, 0-based over widgets
    if Idx < 0 then
      Exit;                    // page holds no widget annotations

    Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
      string(Pdf.FocusedFormFieldValue));   // reads the native focused widget
  finally
    Pdf.Free;                  // page unload runs FORM_OnBeforeClosePage
  end;
end;

能证明这次修复确实有效的检查方法,是把两边对照起来看。用一个逻辑索引调用FocusFormField,然后通过一个真正经过原生获得焦点控件的访问器——比如FocusedFormFieldValueFocusedFormOptionSelected——去读取一个值,而不是通过你自己的记录去读。如果逻辑索引能正确往返,但原生访问器读回来的却是空值,那问题就出在页面视图缺失上,而不是映射逻辑上

逻辑字段索引没有承诺什么

一个从零开始的字段索引只是一种便利,不是一种语义身份,由此引出四条限制。它是按页面划分的,不是按文档划分的,所以第2页上的索引0和第1页上的索引0是两个不同的控件,拿它们互相比较毫无意义。它是按位置排列的,所以插入或删除一个批注,会让改动点之上的每一个缓存索引失效;一个存下来的索引,只有在页面保持已加载且未被编辑的期间才应该被当作有效

第三条限制是审阅字段列表的人最容易感到意外的一条。这个索引枚举的是控件,不是字段。一个单选按钮组是一个字段、带着若干个控件子项,所以一个三按钮的组会贡献三个连续的索引,它们报告的Name全都相同。TPdfFormFieldInfo记录携带了GroupCountGroupIndex正是为了应对这种情况,而一个忽略了它们的列表界面会把同一个字段显示三遍。第四条限制关乎遍历顺序:这里暴露出来的tab顺序,是控件的枚举顺序,它遵循的是/Annots数组,既不是页面的/Tabs条目(ISO 32000-1 §7.7.3.3),也不是AcroForm的字段树。对大多数生产者来说,这三者是一致的;但对一个由某个先输出右列内容的生成器排成两栏的表单来说,它们并不一致,表单字段导航一文中描述的键盘操作路径,即便每一个索引都是对的,感觉起来也会不对劲。当客户的文件表现异常时,先把两套索引空间并排打印出来再去推理原因:把同一个页面的批注视图和字段视图打印在一起,通常一眼就能看出问题所在

procedure DumpIndexSpaces(Pdf: TPdf);
var
  I: Integer;
  Info: TPdfFormFieldInfo;
begin
  for I := 0 to Pdf.AnnotationCount - 1 do
    Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));

  for I := 0 to Pdf.FormFieldCount - 1 do
  begin
    Info := Pdf.FormFieldInfo[I];
    Writeln('field ', I, ': ', string(Info.Name),
      ' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
  end;
end;

一个远高于字段计数的批注计数,意味着这个页面混合了多种子类型,这在经过审阅的文档中很正常,也正是这套映射机制存在的意义;批注审阅工作流一文从标记这一侧审视了同一个页面。反过来说,如果在每一份测试文件上两个计数都相等,那就意味着你的测试样例根本检测不出这一整类bug,诚实的应对方式是添加一份带有链接和便签批注的表单测试样例

这里介绍的字段枚举、焦点和批注API,随PDFium Component一起提供,适用于Delphi、C++Builder和Lazarus,其产品页收录了完整的表单字段参考,包括字段信息记录和焦点访问器