技术文章

Delphi 中的 FDF 注释导入:修复无声的零

在 v3.539.30 之前,losLab PDF Library 的 TPDFlib.ImportAnnotationsFromFDFString 会返回它解析出的 FDF 注释条目数,却一条也没加进文档:每条都被计数,每条都被丢弃。从 v3.539.30 起,FDF 导入器按任意顺序读键、以与区域设置无关的方式正确解析 /Rect,配套的导出器写出注释真实的 /Rect,于是导出、导入、再导出得到逐字节相同的 FDF。这篇笔记讲清楚一个错误的起始偏移如何制造出一场完美的无声失败,它身后藏着哪三个缺陷,以及你怎么自己验证一次导入而不是轻信返回值

场景再普通不过。审阅人在合同上做批注,批注以 FDF 文件的形式传过来(Acrobat 里叫 Export Comments),你的 Delphi 服务用 ImportAnnotationsFromFDF 把它们合并进一份干净副本。调用返回 7,日志写着「7 comments imported」,任务变绿,输出的 PDF 里一条注释都没有。没有异常,没有警告,那个数字看起来还挺可信,因为它确实是文件里条目的真实数量。这是一个 bug 能呈现的最坏形态:一个函数唯一的成功信号是一个计数器,而它的计算与它声称汇报的工作完全脱节

ImportAnnotationsFromFDFString 为什么报成功却什么都没加?

导入器把每个 /Subtype 都读成空字符串,而创建注释的辅助函数一遇到空子类型就提前退出,调用方却照样把结果加一。键查找器返回的是紧挨 /Subtype 之后的位置,也就是值前面的那段空白。ReadName 从那个空格起步、遇到第一个空白字符就停,所以它什么都没读到就停了。AddAnnotationToPage 拒绝构造没有子类型的注释,孤立地看这是正确的防御,但它是个没有返回值的过程,Inc(Result) 在它外面。每道防线单独看都合理;合在一起把「什么都没成功」变成了「一切成功」。修好的 ReadName 会跳过空白、要求 PDF name 对象开头的 /,并在任意定界符处停下,包括 [、( 和 ),所以 /Subtype/Text 和 /Subtype /Text 都得到 Text

PDFlibPas 的 ImportAnnotationsFromFDFString 找到 /Subtype 后让 ReadName 从键后面的空白处起步,返回空名称,AddAnnotationToPage 因缺少子类型退出,调用方却照样递增结果,汇报导入了七条注释而文档里一条没加
每道防线单独看都合理;合在一起把「什么都没成功」变成了「一切成功」,这就是导入测试绝不能只检查返回值的原因

那次修复之后,返回值仍值得留意。直到 v3.539.39,ImportAnnotationsFromFDFString 还是会给 /Annots 数组里每个结构合法的字典递增结果,包括 0 起始的 /Page 越界或 /Subtype 缺失的条目——这两种都会被跳过。从 PDFlibPas v3.539.40 起,ImportAnnotationsFromFDFString 和 ImportAnnotationsFromFDF 返回实际添加的注释数,与 XFDF 导入一致:FDF 辅助函数 AddAnnotationToPage 现在返回 Boolean,计数器只在成功时才前进。量文档本身仍然是更强的检查,因为它在旧版本上也成立,所以下面的草图对导入前后每页的 AnnotationCount 做比较

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // 按选中页统计,控件注解也计入
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // 自 v3.539.40 起两者相等
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

第一个缺陷背后的另外三个

只修子类型会把同一个函数里另外三个 bug 暴露出来,它们此前一直隐身,只是因为从来没有注释真正落到页面上。其一,ReadNumber 把位置当值参数收,顺序读 /Rect 的四个数时四次读的是同一处,而且它不跳过开头的 [,实际上什么都没读到。其二,FindKey 在所有查找之间共用一个只进不退的游标。导出器写出 /Subtype、/Rect、/Page、/Contents、/T、/Subj,导入器却按 /Subtype、/Contents、/T、/Subj、/Page、/Rect 的顺序搜;游标一旦越过 /Contents,对 /Page 和 /Rect 的搜索就越过当前条目,要么一无所获,要么匹配到下一条注释的键。库读不懂自己的输出。其三,数字走 PLStrToFloat,跟随系统小数分隔符。ISO 32000-1 §12.7.7 把 FDF 定义为 PDF 对象语法,而 PDF 里字典的键是无序的(§7.3.7),所以任何假设键顺序的 FDF 解析器从构造上就是错的,无论文件出自哪个工具

修好的导入器先给每个条目定界。FindDictEnd 从开头的 << 走到配对的 >>,跟踪嵌套字典、按反斜杠转义跳过字面字符串体,于是注释里的 >>——比如 (see section >> 4)——没法提前终结条目。之后每次键查找都从条目自己的起点开始、以它的终点为限,键顺序从此无关紧要,一条注释也借不走另一条的 /Page。键匹配还接受名称后紧跟定界符,因为 /Contents(Hi) 与 /Contents (Hi) 同样合法,而词边界规则保证 /Subj 不会匹配到 /Subtype 的开头、/T 不会匹配 /Type。ReadNumber 现在以 var 参数收位置,跳过空白和 [,用 PLTryStrToFloatInvariant 解析,遇到坏 token 软失败而不是抛异常。四个矩形数里任何一个失败,四个全部回落为零,而不是产出一个读了一半的矩形

PDFlibPas 的 FindDictEnd 现在把每条 FDF 注释从开头的 << 定界到配对的 >>,每次键查找都从条目起点重来、在条目终点停下,ReadNumber 改收 var 位置、跳过方括号并用 PLTryStrToFloatInvariant 解析
共用游标读不懂库自己的导出:一旦越过 /Contents,对 /Page 和 /Rect 的搜索就闯进下一条注释的键里,所以键顺序从此不允许再影响结果

为什么 FDF 往返会把每条注释抬高它自己的高度?

旧导出器用了错误的坐标模型写矩形。注释的 /Rect 是默认用户空间下的 [llx lly urx ury](ISO 32000-1 §12.5.2,矩形定义见 §7.9.5),FDF 携带同一个数组。ExportAnnotationsToFDFString 却调 GetAnnotRectEx,后者报告的是库绘图坐标下的 Left、Top、Width、Height——也就是 SetOrigin 控制的那个空间——并序列化成 [L T L+W T+H]。导入器一旦修好,就把这四个值原样写回成 PDF 矩形,于是顶边落在左下角该在的地方,每次往返都把注释抬高它自己的高度。导出器现在直接拷贝注释自己的 /Rect 数值,三位小数、点分隔、无指数,只有存储数组缺失或不是四个数时才回落到计算出的矩形

PDFlibPas 曾把 FDF 的 /Rect 序列化成绘图坐标下的 left、top、width、height,把这四个数当 llx lly urx ury 导回时顶边落在左下角该在的位置,每次往返都把注释抬高它自己的高度
导出器现在拷贝注释自己的 /Rect 数值——三位小数、点分隔、无指数——回归测试把第二次导出与第一次逐字节比较

钉住这个行为的回归测试值得抄走,因为它断言的是文档和第二次导出,而不是导入器的返回值。注意期望值是 2:AddNoteAnnotation 创建一个 Text 注释外加它的 Popup,两个都会走完全程。测试还在逗号小数分隔符下跑导出与导入,这正是这则故事的另一半所在

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // 现在有两页
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // 模拟德语或法语桌面
    try
      FDF := Source.ExportAnnotationsToFDFString;   // 照样写出 /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // 注释本体和它的 popup
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

说清楚 FDF 这条路都带什么。导入器把每条条目重建为带 /Type、/Subtype、/Rect、/Contents、/T 和 /Subj 的字典;颜色、标志、边框样式、popup 链接和外观流不在这条路上,导出器还会跳过 Widget 注释,因为表单字段归表单数据方法管。哪些数据经由哪个方法走、全图见 FDF、XFDF 与 XFA 表单数据交换总览;如果你要检查实际到了什么,GetAnnotType、GetAnnotTitle、GetAnnotContentsEx 这类按索引读取的接口在 书签、注释与动作内省里有讲

旧导出产生的逗号小数 FDF 和 XFDF 文件怎么读?

FDF 的答案毫无歧义:逗号在 PDF 语法里不是定界符,所以一个恰好含一个逗号、不含点号的数字 token 只能是逗号区域机器上写出的十进制数。旧版本确实写过这样的文件,比如 /Rect [10,500 20,250 40,750 60,125],新的 ReadNumber 会在解析前把那唯一的逗号换成点。含两个逗号、或既有逗号又有点号的 token 会被拒绝而不是靠猜。读取端也不消费指数记法,这与 ISO 32000-1 §7.3.3 一致:PDF 数字从不用指数

XFDF 难一些,因为在 XML 属性里逗号是分隔符。标准 XFDF(ISO 19444-1)写 rect="50.5,80.25,70.75,100.125" 和 dashes="4,2",而 v3.539.28 及更早版本在逗号区域系统上写 rect="50,500 80,250 70,750 100,125" 和 opacity="0,600",读标准 opacity="0.6" 时还会抛 EConvertError。从 v3.539.29 起两个方向都不再受区域影响,且 XFDFNormalizeLegacyDecimals 只在属性按空白切分后恰好得到预期数量的 token(rect 四个,opacity 和 width 各一个)、并且每个 token 都是数字-逗号-数字的形状时,才认出遗留形态。标准 rect 永远不会误中:它要么是带三个逗号的单个 token,要么是以逗号结尾的 token。dashes 被刻意放过不动,因为 4,2 既可能是两段虚线长度、也可能是遗留的 4.2,没有任何规则能区分

const
  // 键不按导出器顺序排列,另含旧版逗号区域导出写出的逗号小数
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // 新建文档只有一页
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // 重新导出为 XFDF,点号小数:rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

注释导入测试到底该断言什么?

有用的导入测试断言目标文档的状态,绝不止断言导入器怎么说。整套测试从没在 FDF 导入后查过 AnnotationCount,而返回值——人人盯着看的唯一数字——恰是这个 bug 唯一没碰坏的数字。三条断言就能抓住这里描述的每一个缺陷:期望页上的注释数、经 GetAnnotType 或 GetAnnotContentsEx 读回的一个字段、以及与第一次逐字节比较的第二次导出。同样的纪律适用于任何批量改写文档结构的 API,包括 合并重复表单字段讲的字段整合:检查生成的树,而不是返回的合计。FDF 与 XFDF 注释方法(含文件与字符串变体)随 losLab PDF Library for Delphi and C++Builder 发布;注释必须安全走完全程就跑 v3.539.30 以上,返回数必须与实际添加一致就跑 v3.539.40 以上