HotPDF v2.743.0 不再静默跳过没有 /AP 外观流的 PDF 注释,而是将其展平。FlattenLoadedAnnotations 现在会把没有外观的 widget 交给 EnsureLoadedFieldAppearanceStream,并根据没有外观的标记注释自身属性构建 Form XObject,因此在 /NeedAppearances 表单中输入的值会进入页面内容,而不会在展平时消失。促成这次改动的故障看起来像什么都没做。客户从浏览器打印出一份填好的申请表 PDF。你在 HotPDF 中加载它,调用 FlattenLoadedAnnotations,得到返回值 0,保存后交付的文档中,申请人填写的姓名和金额所在位置只剩下空框。没有抛出错误,也没有日志。那些值一直在文件里,位于每个字段的 /V 条目中;展平过程只是直接绕过了它们,因为这些 widget 一个也没有外观流
为什么浏览器打印的表单在展平时会丢失填写值
因为 /NeedAppearances 表单存储了值,却没有存储值的图像。ISO 32000-1 第 12.7.2 节允许交互式表单在 AcroForm 字典中设置 /NeedAppearances true,告诉查看器在打开时根据 /V、/DA 和 /Q 构建每个字段的视觉表面。廉价生成表单的生产者——浏览器打印路径、服务端填充器以及某些扫描前端——会采用这个机制,完全不写 /AP。按照 ISO 32000-1 第 12.5.5 节的外观算法,展平是一项转录工作:取得注释的普通外观流,将其 /BBox 映射到 /Rect,通过页面内容流中的 Do 操作符调用它,然后删除注释。没有源流就没有可转录的内容。HotPDF 从 v2.386.0 开始的原始实现把这种情况当作“跳过”,单独看有道理,合在一起却很糟:最需要展平的文档,往往正是最不可能带有外观的文档。同一个缺口也会吞掉标记注释——审阅工具生成的 Highlight、修订过程中的 Square、手写批注——只要生产者依赖查看器来绘制它们
HotPDF 将生成逻辑接入 FlattenLoadedAnnotations 的位置
接入点被有意放在较晚的位置:外观查找失败之后,而不是之前。FlattenLoadedAnnotations 仍会首先通过 GetLoadedAnnotationAppearanceStream 请求普通外观,已经有外观的注释会完全按照 v2.386.0 的方式烘焙。只有在返回 nil、注释带有非退化 /Rect 且没有隐藏标志时,才会进入生成路径。顺序很重要:文档作者既然费心写入了 /AP,就应该取回自己的字节,而不是得到 HotPDF 对它的重建
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
if Subtype= 'Widget' then
begin
FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
if FieldIdx>= 0 then
EnsureLoadedFieldAppearanceStream(FieldIdx);
// 再次查询:生成器已经为 widget 附加 /AP /N
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
end
else
NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;
从这里开始,两类注释分开处理。widget 会通过 GetLoadedFormFieldIndexForAnnotation 找回所属字段,再交给 EnsureLoadedFieldAppearanceStream,也就是这个 Delphi PDF 库从 v2.328.0 起就有的字段外观生成器。复用它而不是再写一个字段渲染器,正是关键——它已经覆盖 Type0 字体、换行、对齐、复选框和单选框的 /AS 状态以及 /MK 旋转,这也是向已加载 PDF 添加 AcroForm 字段所使用的同一套机制。其他内容都会进入标记生成器。对调用方来说没有变化:同一个单行展平调用,现在会在过去返回零的文档上返回非零计数
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0:没有 AP 的 widget 和标记注释会先生成外观,再烘焙
Flattened:= Doc.FlattenLoadedAnnotations; // 所有页面,所有子类型
// Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
if Flattened= 0 then
raise Exception.Create('nothing was flattened');
Doc.SaveLoadedDocument('flattened.pdf');
finally
Doc.Free;
end;
为什么 QuadPoints 和 InkList 会落在错误位置
因为这些坐标位于页面用户空间,而生成的外观流在自己的 /BBox 空间中绘制,两个原点并不是同一个点。ISO 32000-1 表 176 为文本标记注释在默认用户空间中定义了 /QuadPoints,表 174 对线注释的 /L 端点做了同样的定义;/InkList 遵循同一约定。HotPDF 为生成的表单设置 [0 0 W H] 的 /BBox,其原点位于 /Rect 的左下角。因此,从 /QuadPoints、/L 或 /InkList 取出的每个点,都必须在写入内容流前减去 /Rect 左下角的坐标。若这里出错,页面上方 700 点处的一条高亮就会在自身框体上方再画 700 点,实际效果就是消失。修正每个坐标只需要一次减法,而且能与烘焙随后发出的 cm 组合——该矩阵将 /BBox 映射回 /Rect,因此两步相互抵消后得到正确的绝对几何位置
// /L 端点位于页面用户空间(ISO 32000-1 表 174);表单的
// BBox 原点位于 /Rect 左下角,因此需要平移 -(RL, RB)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
_FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
_FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;
生成的标记外观实际绘制什么
标记生成器只读取注释字典,不读取其他内容,这让输出可预测,也诚实地承认它无法知道的内容。FreeText 和 Stamp 使用从 /DA 解析出的字体和颜色绘制 /Contents,按照 /Q 对齐,并留出 2 pt 内边距。Square 和 Circle 绘制 re 或由四段弧线组成的 Bezier 轮廓,使用 /C 描边,在存在 /IC 时填充,并使用 /BS /W 指定的宽度。Line 和 Ink 描边它们的顶点。Highlight 填充每个四边形,Underline、StrikeOut 和 Squiggly 则分别在四边形底部、四边形中点或一条单点锯齿线上描边。小于 1 的 /CA 会变成带有 ca 条目的 ExtGState,并在流开头通过 /GSA gs 引用
文本编码由 AcroForm /DR /Font 中、由 /DA 指定的条目决定。如果字体的 /Subtype 是 Type0,HotPDF 会使用带 FEFF 字节顺序标记的 UTF-16BE 十六进制字面量写入字符串;否则会写入转义的字面量字符串,括号和反斜杠会转义,超过 126 的字节会以八进制写入。/DA 中的 Tf 操作符会在 BT 之前发出,这符合规范,因为文本状态会跨越文本对象边界持续存在,也避免了拆解 /DA 字符串。两个限制需要直接说明。换行和对齐使用半 em 或全 em 启发式估算,而不是真实字体度量,因此比例字体上的对齐接近但不精确。另一个限制是,没有可生成内容的子类型——Popup、Link,或唯一内容是图标名称的 Stamp——会返回 nil 并保持不变,完全遵循之前的行为
临时 /Annots 交换会惩罚善意的清理
FlattenOneWidget 是 FlattenLoadedFormFields 使用的逐 widget 路径,任何共享展平循环中的改动都必须尊重它的别名陷阱。它会临时将页面的 /Annots 值替换为单元素数组,让通用展平过程只处理一个 widget,然后在 finally 块中恢复原来的 PHPDFDictionaryItem 指针。恢复操作会写回调用前捕获的字典槽位
DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
DictItem^.Value:= Item; // 如果内部循环释放了该条目,这里就是悬空指针
TemporaryAnnots.Free;
end;
在共享内部循环中增加一个看似合理的整理步骤——数组变空后调用 DeleteValue('Annots'),让保存的页面不携带多余的空数组——这个调用就会释放 DictItem 所指向的字典条目。随后 finally 会通过悬空指针写入,进程以“Invalid pointer operation”崩溃。两个现有测试立即捕获了它,这正是它仍然只是脚注而没有变成支持工单的唯一原因。这个规则可以推广:向共享循环添加清理之前,先检查调用方是否存在别名或交换契约。残留的空 /Annots 数组只是外观瑕疵,不值得用指针生命周期保证去交换
哪些内容不会烘焙,以及展平的代价
隐藏注释会被有意排除。按照 ISO 32000-1 第 12.5.3 节,/F 整数的第 2 位被设置时,注释就是隐藏的;当它又没有 /AP 时,很容易想要生成一个外观并像其他注释一样烘焙。这会带来安全后果,因此是错误做法:把不可见批注烘焙进页面内容,会让任何打开文件的人都能看到它。HotPDF 会让这些注释完全保持原状,也不会把它们计入返回值。对于确实被烘焙的内容,也要向用户说清楚代价。展平不可逆——注释会从页面的 /Annots 数组中删除,其视觉结果现在成为页面内容,因此不能再编辑字段值、维护评论线程、切换 /AS 状态,也无法恢复结构化数据,除非保留原始文件。请展平副本并保留原件,只在文档从表单变成记录时使用。如果问题来自 XFA 而不是缺少外观,应该从 HotPDF 独立的XFA 到 AcroForm 展平路径开始;如果还在构建表单,则连接 AcroForm 字段操作与验证的说明覆盖写入侧
还有一个验证方面的注意事项,否则它会让你浪费一个下午。ExtractLoadedPageGlyphs 不会进入 Form XObject,而烘焙后的外观正位于其中——页面内容流只包含 q ... cm /FlatAn<n> Do Q 序列。因此,在展平页面上做字形提取会报告空结果,这是正确行为,而不是烘焙结果丢失。应当在字节层检查 /FlatAn 资源名、Do 调用和 /Subtype /Form,或者通过会展开 XObject 的渲染流水线验证
注释展平看起来像三行转录工作,直到你遇到人们实际生成的文档。如果你在 Delphi 或 C++Builder 中处理填充表单、审阅标记或归档输出,那么在自己基于它构建外观生成器之前,值得先阅读 HotPDF Delphi PDF 组件如何处理已加载文档一侧的 AcroForm 和注释