技术文章

PDFium Component 注释外观字典的往返污染

在 v3.121.1 之前的 PDFium Component 里,通过 TPdf.Annotation[] 读出一条注释、再把记录原样赋回去,可能往它的 /AP 外观字典里添上空的 /R 和 /D 条目——哪怕原文档只带 /N。PDF/A 校验器会拒收这种字典。从 v3.121.1 起,getter 只报告它真正读到的外观,未做修改的往返不再写出任何新东西。这个失败值得细看,因为最常见的触发场景恰恰是一次想让文件更合规的修复,而不是相反

PDFium Component 注释往返示意:通过 TPdf.Annotation[] 与 SetAnnotationData 加 afPrint 时,FPDFAnnot_SetAP 也写出了空的 /R 和 /D 流,把 PDF/A 干净的外观字典变成 veraPDF 拒收的字典,直到 v3.121.1 只报告真正读到的外观
读注释再原样写回,过去会添上空的 rollover 与 down 外观流——让 PDF/A 失败的是这个,不是你想加的 Print 标志

把注释原样写回时,哪里出了问题?

简短回答:注释平白多出了它从未有过的外观流,编辑前能过 PDF/A 校验的文件,编辑后就过不了。典型场景是这样的:客户档案送来一批缺少 Print 标志的 square 和 text 注释,PDF/A 要求每条注释都可打印,于是你遍历各页、加上 afPrint、把每条记录赋回去。这段代码没有任何一处碰外观。从 TPdf.Annotation[] 拿到的是一条 TPdfAnnotation,SetAnnotationData 会写出所有 Has* 哨兵已置位的字段——HasContents / ContentsText 这对组合本来就是这么用的。问题出在 getter 给不存在的外观模式把 HasAppearanceRollover 和 HasAppearanceDown 置成 True、配空字符串,setter 又尽职地把两条空流写了出去:

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    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 not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // v3.121.1 之前,源注释只有 /AP/N 时,这条赋值还会
          // 写出空的 /AP/R 和 /AP/D 流
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5 给外观字典定义了三个条目:/N 是正常外观,/R 是 rollover,/D 是按下。/R 和 /D 可选,缺席时阅读器回退到 /N。但一条空的 /R 流不等于缺席:它是一条合法的、什么都不画的流,尊重 rollover 外观的阅读器会在指针移到注释上的一瞬显示一块空白矩形。PDF/A 更严:ISO 19005-1(含 Corrigendum 2)与 ISO 19005-2 / 19005-3 只允许注释外观字典里有 /N。veraPDF 对往返后的文件报 PDF/A-1 的 rule 6.5.3-4、PDF/A-2 与 PDF/A-3 的 rule 6.3.3-2,内建的 TPdf.ValidatePdfA 则把它列为 pvaiAnnotationApDictViolation。为了满足标准某一条而加 Print 标志的编辑,打破了另一条

ISO 32000-1 的注释外观字典,含 normal、rollover 与 down 三个条目:对流缺失和流存在但为空,PDFium 都返回 2 字节,经 TPdf 读回来都是无内容;只有 TPdf.ValidatePdfA 这类字节级检查才能找出 PDF/A 不允许的空流
/R 缺失时回退到 /N;空的 /R 画出空白矩形且照样过不了 PDF/A,而经由记录读出的两者无法区分

FPDFAnnot_GetAP 为什么对缺失的外观返回 2?

PDFium 从 FPDFAnnot_GetAP 永远不返回零,哪怕请求的外观流根本不存在。这个函数遵循 PDFium 常见的两次调用模式:先传 nil 缓冲拿所需的字节长度,分配后再调一次拷贝 UTF-16LE 文本。长度总是包含 UTF-16 终止符,所以缺失的流报告 2 字节——空字符串加终止符。v3.121.1 之前的 getter 测试的是 ByteLength >= SizeOf(FPDF_WCHAR),这个条件每次调用都为真,于是对任何带任何外观的注释,三个 HasAppearance* 标志统统返回 True。随后经过记录的往返就让 FPDFAnnot_SetAP 为每个模式存一个空字符串,PDFium 为此创建了流。没有异常,没有警告,可见页面看起来一模一样——所以这个缺陷是在 veraPDF 测试夹具里现形的,而不是在阅读器里

PDFium 里 FPDFAnnot_GetAP 如何报告缺失的外观流:两次调用模式对 UTF-16 终止符总返回至少 2 字节;旧的门槛与 SizeOf(FPDF_WCHAR) 比较每次都通过、把所有 HasAppearance 哨兵置真;v3.121.1 的门槛要求多于终止符且字节数为偶数
2 字节是编码后的空字符串,不是外观存在的证据;修复后的 getter 把不高于终止符长度的结果一律当作无内容,写回侧保持沉默

v3.121.1 如何判定一个外观存在

ReadAppearance 是 GetPageAnnotation 里填充 AppearanceNormal、AppearanceRollover 和 AppearanceDown 的辅助函数,现在只有结果至少带终止符之外的一个字符时才算内容。第一次调用必须返回多于 SizeOf(FPDF_WCHAR) 字节且字节数为偶——奇数长度不可能是 UTF-16。真正拷贝文本的第二次调用还要再验一遍:返回长度小于等于 2,或大于已分配的缓冲,都会把 HasValue 重置为 False、字符串留空。写入侧没有任何变化:SetAnnotationData 仍然只为 HasAppearance* 标志为 True 的模式调用 FPDFAnnot_SetAP,所以从只有 /N 的注释读出的记录现在只写回 /N。回归夹具覆盖了双向:一条带正常外观的 square 注释,读出再原样写回,通过 PDF/A-1b、PDF/A-2b 和 PDF/A-3b;同一条注释去掉 Print 标志后,只在预期的标志规则上失败,别无其他

缺失与空流看起来一样,所以 getter 保持保守

原生 API 分不清缺失的外观流和存在但为空的外观流,PDFium Component 也不假装分得清。两种情况从 FPDFAnnot_GetAP 返回的都是同样的 2 字节,于是经记录读回都是 HasAppearanceRollover = False 加空的 AppearanceRollover。这带来两个你该围绕它设计的事实。第一,False 哨兵的含义是「没读到内容,写回时不会动这个模式」,不是「字典里没有 /R 键」。第二,记录无法发现文件里已有的空流:被旧版本构建或其他工具损坏的文档读回来是干净的,把记录赋回去既不修复也不恶化它。要找出这类文件需要字节级检查,这正是 TPdf.ValidatePdfA 和PDFium Component 的 PDF/A preflight 校验工作流的用武之地

想刻意清空一个外观怎么办?

显式置位哨兵、传入空字符串,setter 就会照写。在 SetAnnotationData 里禁止空字符串本是这个 bug 最省事的修法,但那也会破坏刻意清空外观的调用方——与 HasContents 和 HasAuthor 对文本遵守的是同一份契约。所以修复全部落在 getter 里,setter 继续尊重调用方的一切要求:

// 先替换 rollover 外观,再把它清掉
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover 为 True,文本按 'q Q' 完整往返
A.HasAppearanceRollover := True;   // 显式重申意图
A.AppearanceRollover := '';        // 刻意写出一条空流
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// 读回为 HasAppearanceRollover = False 加空字符串:
// 空流与缺失流在这里无法区分

记住:按上文引用的 PDF/A 规则,被显式清空的 /R 或 /D 仍然算一个多余的键。目标若是归档 profile,唯一能通过校验的形状是写出非空的 /N、其余两个模式一概不碰。任何在文档间搬运注释的工作流也一样,比如用 PDFium Component 做 XFDF 导入导出:复制源文档真正有过的模式,其余哨兵保持 False

一个不破坏 PDF/A 的读改写模式

升级到 v3.121.1 或更高版本,让外观哨兵保持 getter 返回时的原样,并在发布前校验保存后的文件。因为残留的空流读回来等于缺席,验证这一步必须看序列化之后的文档而不是记录,而且每批跑一次代价也很小:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa 声明了 TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // 校验 Pdf 当前加载的文档,包括打开之后
  // 通过 Pdf.Annotation[] 做过的编辑
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

同样的纪律适用于任何为评审而给页面改色或加注的面板,用 PDFium Component 构建 Delphi 注释评审工作流覆盖了这类工作流:记录只是引擎能读到内容的快照,不是你自己设置的哨兵应当原样传回。完整的注释 API、PDF/A preflight 和原生 PDFium 引擎一起随PDFium Component for Delphi, C++Builder and Lazarus 发布