把一整块表单字段从去年的模板搬到今年的版式上,正是 FDF 和 XFDF 往返不再够用的场景:值到了,但外观流、计算动作和默认资源没到。PDFiumPas 用 GraftPdfAcroForm 应对这种情形,它把整张字段对象图从一个 PDF 克隆出来,写进另一个 PDF
数据级导出做不到这件事的原因是结构性的。字段不是一条记录,它是一个子图。ISO 32000-1 §12.7 定义了容纳 /Fields、/CO、/DR 和 /DA 的交互式表单字典,§12.7.3 定义了挂在它下面的字段字典,§12.5.6.19 定义了让这些字段在页面上有可见方框的控件注释。XFDF 携带的是这套结构的叶子。移植携带的是结构本身
为什么复制 /Fields 数组永远不够
把 /Fields 从一个文档复制进另一个,产出的表单会以各种有意思的方式坏掉,因为这个数组装的全是间接引用,别无他物。ISO 32000-1 §7.3.10 让间接对象靠对象号加世代号寻址,而这些编号只在它们来源的文件内部有意义。把数组粘贴过去,里面每个引用要么悬垂,要么更糟——悄悄解析到目标文件里恰好占着那个槽位的无关对象。每个引用底下是一张既共享又成环的图。字段字典指向它的子项,每个子项指回它的 /Parent,控件指向它的外观流、并通过 /P 指向承载它的页面,外观流指向表单默认资源字典里的字体,/AA 下的附加动作字典又指向更多对象。不同页面上的两个控件经常共享一个字体和一个外观 XObject。所以正确的移植必须遍历那张图,把每个可达对象恰好克隆一次,把每个控件的 /P 重定向到映射后的目标页面,并把克隆出的控件加进该页的 /Annots 数组——否则字段存在于表单中却在页面上不可见。如果你追究过字段、它的控件与显示它的页面注释之间的区别,我们关于控件索引与注释索引的笔记讲的正是这个分野
GraftPdfAcroForm 需要你提供什么?
它需要三个彼此独立的流和一份显式页面映射。GraftPdfAcroForm 接收作为独立 TStream 实例的 Source、Destination 和 Output、一个 TPdfGraftPageMappings 数组、一个 TPdfAcroFormGraftOptions 记录、一个可选的 TPdfCrossDocumentGraftMap,以及一个传出的 TPdfAcroFormGraftReport。它返回 Boolean 而不是抛异常,失败时报告在 ErrorMessage 里带着原因。页面映射两侧都是一基的,且不会被推断:每个承载了你打算移植控件的源页面都必须出现在里面。给移植映射传 nil 是正当的——函数会在调用期间创建并释放一个私有的——TPdfAcroFormGraftOptions.Default 给你的是 CollisionPolicy 设为 pagcpReject、RenamePrefix 设为 Imported_、MaxObjects 为 100000、MaxDepth 为 128、AllowSignedDestination 设为 False。最后三个是预算,它们存在是因为你即将遍历的对象图来自一个不是你写的文件
uses
Classes, SysUtils, FPdfCompress;
var
Source, Destination, Output: TMemoryStream;
Options: TPdfAcroFormGraftOptions;
Mappings: TPdfGraftPageMappings;
Report: TPdfAcroFormGraftReport;
begin
Source := TMemoryStream.Create;
Destination := TMemoryStream.Create;
Output := TMemoryStream.Create;
try
Source.LoadFromFile('claim-template-2025.pdf');
Destination.LoadFromFile('claim-layout-2026.pdf');
Source.Position := 0;
Destination.Position := 0;
Options := TPdfAcroFormGraftOptions.Default;
SetLength(Mappings, 2);
Mappings[0].SourcePageNumber := 1;
Mappings[0].DestinationPageNumber := 1;
Mappings[1].SourcePageNumber := 2;
Mappings[1].DestinationPageNumber := 3;
if GraftPdfAcroForm(Source, Destination, Output, Mappings,
Options, nil, Report) then
Output.SaveToFile('claim-2026-with-fields.pdf')
else
raise Exception.Create(Report.ErrorMessage);
finally
Output.Free;
Destination.Free;
Source.Free;
end;
end;
移植映射如何避免把共享字体克隆两次?
TPdfCrossDocumentGraftMap 持有一张源到目标的引用表,键同时带对象号和世代号,递归克隆器在下潜之前先查它。操作顺序是环安全的关键:克隆器先分配目标对象号并注册映射,然后才遍历源对象的子引用。父对象到达一个指回父对象的子项时,会发现父对象已注册,于是返回已有的目标引用而不是递归。同一次查找让被六个控件共享的字体、外观流或动作只克隆一次、被引用六次。映射通过源字节的 SHA-256 哈希与源文档绑定,暴露为 SourceIdentity。如果你交给 GraftPdfAcroForm 一张身份与你传入的源不匹配的映射,它会拒绝调用,而不是复用对这个文件从无效过的引用。页面映射在克隆开始前就播进同一张映射,这正是控件的 /P 最终指向目标页面的方式:源页面对象已经解析到映射后的目标页面对象,所以普通的引用重写趟无需特殊情况就处理了它
uses
Classes, SysUtils, FPdfCompress, FPdfSha256;
var
GraftMap: TPdfCrossDocumentGraftMap;
SourceBytes: TBytes;
EntriesBefore: Integer;
begin
SetLength(SourceBytes, Source.Size);
Source.Position := 0;
if Length(SourceBytes) > 0 then
Source.ReadBuffer(SourceBytes[0], Length(SourceBytes));
GraftMap := TPdfCrossDocumentGraftMap.Create(
AnsiString(SHA256Hex(SHA256Bytes(SourceBytes))));
try
EntriesBefore := GraftMap.Count;
Source.Position := 0;
if not GraftPdfAcroForm(Source, Destination, Output, Mappings,
Options, GraftMap, Report) then
begin
// 本次调用添加的条目已被回滚;
// 它之前注册的任何东西仍完好。
Assert(GraftMap.Count = EntriesBefore);
WriteLn('graft refused: ', Report.ErrorMessage);
end;
finally
GraftMap.Free;
end;
end;
那个回滚正是你自己拥有映射的意义。PDFiumPas 以事务方式对待调用方提供的映射:失败的移植丢弃本次调用添加的条目,保留此前已存在的每个映射,所以一次拒绝绝不会留下一缓存指向从未写入对象的引用。不过要为每个目标文档保留一张映射——每个条目的目标侧是那个特定文件里的对象号,在另一个文件里毫无意义
字段名冲突:拒绝还是重命名
全限定字段名在表单内必须保持唯一,冲突时 PDFiumPas 不会猜你的意图。TPdfAcroFormCollisionPolicy 恰好提供两个答案。在默认的 pagcpReject 下,第一个标题已存在于目标中的源字段会带错误中止整次移植,并让输出流保持为空。在 pagcpRename 下,冲突的源字段通过加 RenamePrefix 前缀被重命名,移植继续,Report.RenamedFieldCount 告诉你这事发生了多少次
Options := TPdfAcroFormGraftOptions.Default;
Options.CollisionPolicy := pagcpRename;
Options.RenamePrefix := 'Y2025_';
Options.MaxObjects := 20000;
Options.MaxDepth := 64;
if GraftPdfAcroForm(Source, Destination, Output, Mappings,
Options, nil, Report) then
begin
WriteLn('source fields : ', Report.SourceFieldCount);
WriteLn('existing fields: ', Report.DestinationFieldCount);
WriteLn('grafted fields : ', Report.GraftedFieldCount);
WriteLn('renamed fields : ', Report.RenamedFieldCount);
WriteLn('cloned objects : ', Report.GraftedObjectCount);
WriteLn('reused objects : ', Report.ReusedObjectCount);
WriteLn('mapped pages : ', Report.MappedPageCount);
WriteLn('output bytes : ', Report.OutputByteCount);
end
else
WriteLn('graft refused : ', Report.ErrorMessage);
重命名不是免费的,你应当刻意决定它,而不是伸手用它让错误消失。被重命名的字段是另一个字段:目标中任何按名寻址它的 JavaScript、/CO 里任何人针对旧名写下的计算条目、以及任何按键段名索引的下游消费者,都需要知道这个前缀。如果两个文档确实描述同一个字段,诚实的修法通常是在上游对齐名字,而不是在移植时。移植落地后,遍历合并后的表单确认你实际得到了什么,是自然的下一步,PDFiumPas 中的表单字段导航讲的就是那次遍历
移植刻意失败即拒的地方
每个歧义条件都是错误,绝不是尽力而为的结果,这是一个值得在它于生产中吓到你之前理解的设计决定。GraftPdfAcroForm 碰到以下任何一条时返回 False、重置输出流并报告原因
- 源表单带
/XFA条目——XFA 包是平行的表单模型,不能归约成 AcroForm 字段字典 - 某个控件所在的源页面在页面映射中没有条目,否则会悄悄丢掉字段或把它挂到错误页面
- 页面映射越界,或两个映射复用同一个源页面或目标页面
- 两个表单都定义了默认资源字典
/DR,因为合并两个资源命名空间会有把已有名字重指向不同字体的风险 - 对象图超过
MaxObjects或递归超过MaxDepth - 目标包含签名且
AllowSignedDestination为False - 提供的移植映射属于另一个源文档,或某个源引用悬垂
写入路径同样保守。PDFiumPas 把结果作为追加到目标的稀疏增量修订发出,然后重新物化写出的输出并重读它的表单:如果结果的字段数不等于目标原有字段数加源字段数,整次移植被拒绝,输出被清空。你永远不会得到部分移植的文件。这个策略的代价是真实的——/DR 冲突或带签名的目标会直接拦下你,你得自己解决而不是接受一个合并近似物——但替代方案是一个打开正常、计算错误的表单
什么时候移植是错误的工具
移植移动的是结构,所以当你缺的正是结构时用它。如果两个文档已带同一组字段、你只需要在它们之间移动值和注释,XFDF 表单数据文章里的导出导入路径更轻、标准且可逆。当目标完全没有字段、或有一组不同的字段,而你需要控件、外观流、动作和计算顺序完好地过来时,再伸手用 GraftPdfAcroForm。关于身份最后一条实用提醒:因为移植映射按键段号加世代号建键并绑定源字节的 SHA-256,在两次运行之间重新保存或优化源会产生不同身份和一张不再适用的映射。给你移植所用的源做快照并在整批期间保持稳定;把它当作输入工件,而不是夜间作业可以随意重写的东西
GraftPdfAcroForm、TPdfCrossDocumentGraftMap 及周围的流级 PDF 工具集随面向 Delphi、C++Builder 和 Lazarus 的 PDFiumPas Delphi PDFium Component 一起交付,产品页上有移植选项、报告字段和其余文档编辑面的完整 API 参考