在 v3.121.1 之前的 PDFium Component 里,通过 TPdf.Annotation[] 读出一条注释、再把记录原样赋回去,可能往它的 /AP 外观字典里添上空的 /R 和 /D 条目——哪怕原文档只带 /N。PDF/A 校验器会拒收这种字典。从 v3.121.1 起,getter 只报告它真正读到的外观,未做修改的往返不再写出任何新东西。这个失败值得细看,因为最常见的触发场景恰恰是一次想让文件更合规的修复,而不是相反
把注释原样写回时,哪里出了问题?
简短回答:注释平白多出了它从未有过的外观流,编辑前能过 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 标志的编辑,打破了另一条
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 测试夹具里现形的,而不是在阅读器里
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 发布