想从 Delphi 把源文件挂到 PDF/A-3 文档上,PDFium Component 写出的是一条 PDF 2.0 关联文件链:带 MIME /Subtype 的嵌入文件流、携带 /AFRelationship 的文件规格,以及挂在 catalog 或页面上的 /AF 数组。InjectAssociateFiles 和 TPdf.SaveAsWithAssociateFiles 用一次增量更新建好这条链,且从 v3.121.2 起,MIME 类型序列化成单个、转义正确的 PDF name。本文余下部分讲校验器查什么、弄坏 text/plain 的一字符 bug,以及老版本悄悄做别的事情的几处地方
PDF/A-3 关联文件到底需要什么?
PDF/A-3 附件要过校验,三个对象必须互相咬合:嵌入文件流声明 /Type /EmbeddedFile 加 MIME /Subtype;文件规格字典(ISO 32000-2 §7.11.3)携带 /F、/UF、/EF 和 /AFRelationship;文档里还有东西通过 /AF 数组(ISO 32000-2 §14.13)引用这份文件规格。走 /Names /EmbeddedFiles 名称树的普通嵌入——也就是 TPdf.CreateAttachment 做的事——压根不设置任何关联字段。PDFium Component 自家的 PDF/A-3b 校验夹具把这种依赖变得具体:只改 /AFRelationship 键名,文件恰好挂掉 ISO 19005-3 第 6.8 章的一条规则;只去掉 MIME /Subtype,挂掉的是另一条 6.8 规则;把同一附件放进 PDF/A-1b 候选文件则直接拒收,因为 PDF/A-1 禁止嵌入文件,元数据再干净也没用
关系值是最容易靠猜的部分。FPdfAssocFiles 里的 TPdfAFRelationship 把每个枚举成员映射到注入器能发出的名称令牌,其中只有前五个属于 ISO 19005-3 认可的子集:
afSource→/Source:PDF 由之产出的原件,比如文字处理文件或电子表格afData→/Data:可见内容由之推导或所代表的机器可读数据afAlternative→/Alternative、afSupplement→/Supplement、afUnspecified→/UnspecifiedafEncryptedPayload、afFormData、afTemplate:PDF 2.0 新增,落在 PDF/A-3 子集之外,归档输出请避开
/Subtype /text/plain 为什么弄崩了校验?
这个 MIME bug 是词法切分错误,不是合规缺口:v3.121.2 之前,注入器把调用方的字符串直接拼在斜杠后面,产出 /Subtype /text/plain。按 PDF 语法,第二个斜杠开启一个新的 name 对象(ISO 32000-1 §7.3.5),于是流字典里突然出现了键 /Subtype、值 /text,外加一个多出来的悬空 name /plain,键值对就此失衡。独立的 PDF/A 校验器在解析 EmbeddedFile 字典时就拒收了文件,根本没走到任何 PDF/A 规则——所以这个失败看起来像文件损坏,而不是缺了个附件属性
修复让 MIME 值过一遍 EscapePdfName,产出 /text#2Fplain:一个解码后为 text/plain 的 name。转义范围刻意比斜杠更宽:小于等于 32 的每个字节(空格、tab、CR、LF)、大于等于 127 的每个字节、分隔符 ()<>[]{}/%,以及转义字符 # 本身,一律变成 #XX。只转义斜杠会留下另一个洞:含 >> 或空白的 MIME 字符串可能提前关闭字典或注入多余的键,所以回归测试喂入一个带全部分隔符外加 tab、LF、CR 的恶意值,并核对确切的编码输出
// 注入器对 MIMEType = 'text/plain' 写出的内容
// v3.121.2 之前: /Type /EmbeddedFile /Subtype /text/plain (两个 name)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (一个 name)
//
// 调用方永远传普通的 MIME 值。自己先转义一遍
// 会把 '#' 双重编码,把 'text#2Fplain' 变成 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';
用 InjectAssociateFiles 构建 PDF/A-3 文件
要产出 PDF/A-3,先用 TPdf.SaveAsPdfAToStream 生成合规的基础文档,再对那份流调用 InjectAssociateFiles;校验夹具在通过 PDF/A-3b 之前跑的正是这条两步管线。TPdf.SaveAsWithAssociateFiles 是便捷封装,但它走的是带 saRemoveSecurity 的普通 SaveAs 路径而非 PDF/A 写入器,因此不会添加 PDF/A 要求的 XMP 标识和输出意图。注意记录类型住在 FPdfAssocFiles 和 FPdfPdfa,两个单元都得进你的 uses 子句。从 v3.121.3 起,FileName 和 Description 不必再是纯 ASCII:/UF 和 /Desc 写成 PDF 文本字符串——可打印 ASCII 原样写,其余写带字节序标记的 UTF-16BE;遗留的 /F 名则永远是可移植的可打印 ASCII,其余字符一律换成 _,用自己的代码页解码 /F 的阅读器看到的是下划线而不是乱码。更早的构建在 Delphi 上经系统 ANSI 代码页转换三者、在 Free Pascal 上写裸 UTF-8 字节,所以只有当老构建必须产出相同输出时才把名字保持为 ASCII
uses
System.SysUtils, System.Classes, System.IOUtils,
PDFium, FPdfPdfa, FPdfAssocFiles;
procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
PdfAOptions: TPdfASaveOptions;
Options: TAssocFilesOptions;
Base: TMemoryStream;
Output: TFileStream;
begin
PdfAOptions := TPdfASaveOptions.Default;
PdfAOptions.Conformance := pac3b;
Options := TAssocFilesOptions.Default; // TargetPage = 0:catalog 级 /AF
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'invoice-data.xml';
Options.Files[0].Description := 'Structured invoice data';
Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
Options.Files[0].Relationship := afData;
Options.Files[0].MIMEType := 'application/xml'; // 写成 /application#2Fxml
Base := TMemoryStream.Create;
try
if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
raise Exception.Create('PDF/A-3 base save failed');
Output := TFileStream.Create(OutPath, fmCreate);
try
InjectAssociateFiles(Base, Output, Options); // 回卷 Base;失败抛 EPdfAssocFilesError
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
catalog 还是页面:/AF 数组落在哪?
TAssocFilesOptions.TargetPage 决定 /AF 数组的归属:0 挂到 catalog 上作为文档级关联,1..N 挂到对应页字典(从 1 起算)。注入器把一切作为单次增量更新按固定布局追加(嵌入流、然后文件规格、然后 /AF 数组、最后重写的 catalog 或页对象),既有对象保持各自的偏移,也没有任何东西被重新压缩。目标字典上既有的 /AF 条目是替换而非合并,这让重复保存幂等,但也意味着第二次调用换一份文件列表就能胜出。有两种行为过去需要你在自己的代码里加护栏,如今都已改变。v3.122.0 之前,越界的 TargetPage 不会失败,而是回退到 catalog,于是一个笔误就把页面级关联悄悄变成文档级。从 v3.122.0 起,TargetPage 落在 0..PageCount 之外时,SaveAsWithAssociateFiles 和 SaveAsWithAssociateFilesToStream 抛 EPdfError;TargetPage 为负或指向不存在的页时,InjectAssociateFiles 抛新的 EPdfAssocFilesError,目标流保持原样。v3.121.4 之前,页查找按文件顺序扫描保存字节里的 /Type /Page 字典,一旦页对象的存储顺序与显示顺序不一致——比如页被重排或插入过——文件就可能挂到另一页上;从 v3.121.4 起,TargetPage 指文档页序中该位置的那一页
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// v3.122.0 起,越界的 TargetPage 抛 EPdfError(老构建
// 悄悄回退到 catalog 级 /AF);先检查可以点名具体页
if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);
Options := TAssocFilesOptions.Default;
Options.TargetPage := PageNumber;
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'chart-data.csv';
Options.Files[0].Content := CsvBytes;
Options.Files[0].Relationship := afSource;
Options.Files[0].MIMEType := 'text/csv';
if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
raise Exception.Create('Associated-file save failed');
end;
怎么可靠地读回 AFRelationship?
TPdf.AttachmentRelationship[Index] 经原生导出 FPDFAttachment_GetAFRelationship 返回附件的 /AFRelationship 名称,但空字符串有两种可能的含义,所以先调 AttachmentRelationshipFeaturesAvailable。绑定是宽容加载的:PDFium DLL 缺这个导出时,所有关系都读成空,这与一份「干脆没有 /AFRelationship」的文件规格无法区分。该属性还与 AttachmentCount 共用索引,后者数的是 /Names /EmbeddedFiles 树里的条目。注入器只写 /AF 链、不加名称树条目,所以经 InjectAssociateFiles 挂上的文件在这个索引之外;要确认注入的链,请检查保存后的字节或跑一个 PDF/A 校验器。那棵名称树的内部结构在用 PDFium Component 在 Delphi 中处理 PDF 附件里有讲
procedure ReportRelationships(const FileName: string);
var
Pdf: TPdf;
I: Integer;
Rel: string;
begin
if not AttachmentRelationshipFeaturesAvailable then
begin
Writeln('This PDFium build cannot report /AFRelationship');
Exit; // 空答案有歧义,所以干脆别问
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for I := 0 to Pdf.AttachmentCount - 1 do
begin
Rel := Pdf.AttachmentRelationship[I];
if Rel = '' then
Rel := '(no /AFRelationship)';
Writeln(Pdf.AttachmentName[I], ': ', Rel);
end;
finally
Pdf.Free;
end;
end;
SaveAsWithAssociateFiles 不保证什么?
TPdf.SaveAsWithAssociateFiles 保证的是文件格式外壳和请求的文件确实注入了,而不是合规。注入部分是新改的:v3.122.0 之前,保存字节没有可读 trailer 或定位不到 catalog 字典时,InjectAssociateFiles 会把输入原样拷过去,方法照样返回 True。从 v3.122.0 起,InjectAssociateFiles 在这些情况下先抛 EPdfAssocFilesError 再写任何东西,SaveAsWithAssociateFiles 返回 False;而且它现在先在保存存储里构建完整输出、再打开目标文件,被拒或失败的保存不再截断既有文件。空 Files 数组仍按设计原样拷贝文档。载荷内容也是你的责任:注入器不检查 XML 是否良构、MIME 类型与字节是否匹配、基础文档到底是不是 PDF/A。校验器看过之前,把最终文件当作未验证——这与PDFium Component 与 PDF/A 归档合规里描述的是同一门纪律。如果你自己也解析进来的字典,同样的 #XX name 规则反着用即可,见解析 PDF 字典时的 name 令牌陷阱
关联文件、PDF/A 输出、附件元数据与校验都在同一个组件里交付,上面的管线不需要构建里出现第二个 PDF 库。API 参考、试用下载与授权选项见PDFium Component 产品页