PDF/A-3 的检查清单通常会说 XMP 包必须携带两个标识条目:pdfaid:part 和 pdfaid:conformance。一份写对了的 PDF/A-4 文件携带 pdfaid:part 4 和 pdfaid:rev 2020,而基础 profile 根本没有 conformance 条目。加一个字母去"修复"它反而把文件改错了:ISO 19005-4 用编号和修订年份标识基础部分,只有两个扩展保留字母
PDF/UA-2 在下一层——结构树里——也有一个类似的陷阱。在自己的命名空间里声明一个自定义角色,看起来就是全部工作,因为命名空间正是 part 2 新增的特性。那只是一半。像 https://example.com/ns/widgets 里的 WidgetGroup 这样的角色,仍然要先映射到标准角色,合规校验器才会接受它,而渲染出来的页面上没有任何痕迹提示映射缺失
用 PDFACompliance 选择 PDF/A-4
HotPDF 里 PDF/A-4 的三个 profile 是既有 PDFACompliance 属性的三个取值:'4' 是基础部分,'4E' 是工程文档,'4F' 是携带嵌入文件的文件。选 '4' 时,XMP 包得到 pdfaid:part 4 和 pdfaid:rev 2020,pdfaid 命名空间下再无其他;'4E' 和 '4F' 加上带 E 或 F 的 pdfaid:conformance。解码函数报告同样的区分:三个值下 PDFAPart 都返回 4,PDFAConformanceLevel 返回 E、F,基础 profile 则返回 #0
三者都强制 PDF 2.0 头。升级发生在 EndDoc 内部,从 PDF 1.4 开始的文档不需要额外调用就能以 2.0 出来。唯一拦住它的是 StrictVersionLock:这个标志禁止自动升级,于是 EndDoc 抛出"PDF/A-4 requires PDF 2.0; StrictVersionLock prevents the version upgrade",而不是写出一份元数据里自称 part 4 的 1.7 文件。为老打印工作流钉死版本的代码库,第一次有人翻动 profile 时就会看到这条消息
PDF/A-4 有两条规则改变文件尾和目录里的内容。ISO 19005-4 §6.1.3 只在 Catalog 带 PieceInfo 条目时才允许文档信息字典,而且即便那时也只限于 ModDate,所以 HotPDF 对 part 4 不写 Info 字典,标题以 XMP 里 dc:title 的身份出行。Title 和 Lang 仍然是必填项,任一留空 EndDoc 都会抛异常。没有经 AddPDFAOutputIntent 添加输出意图时同样抛异常;选择了任何 PDF/A profile 时 EnableEncrypt 拒绝运行——加密的归档文件是这个标准不容许的矛盾
PDF/A-4f 与 4e 下的关联文件
关联文件是选 PDF/A-4f 的主要理由:渲染页面背后的一份 XML 发票、下游系统要解析的一份 JSON 负载。AddPDFAssociatedFile 接收文件名、MIME 类型、描述、一个 AFRelationship 值和字节,返回间接 FileSpec 字典,你可以再附上自己的可选键
var
Pdf: THotPDF;
Icc: TFileStream;
begin
Pdf := THotPDF.Create(nil);
Icc := TFileStream.Create('sRGB2014.icc', fmOpenRead or fmShareDenyWrite);
try
Pdf.FileName := 'invoice-archive.pdf';
Pdf.PDFACompliance := '4F'; // part 4,rev 2020,conformance F
Pdf.Title := 'Invoice 2026-0731'; // 只写入 XMP dc:title
Pdf.Lang := 'en-US';
Pdf.BeginDoc;
Pdf.AddPDFAOutputIntent('sRGB IEC61966-2.1', 'sRGB IEC61966-2.1',
Icc, 3, 'DeviceRGB');
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-0731');
// LoadInvoiceBytes 是你自己的、返回 TBytes 的函数
Pdf.AddPDFAssociatedFile('invoice.xml', 'text/xml',
'Structured invoice data', 'Data', LoadInvoiceBytes);
Pdf.EndDoc;
finally
Icc.Free;
Pdf.Free;
end;
end;
每次调用做四件事:写出一个 EmbeddedFile 流,/Subtype 里是作为 PDF 名字的 MIME 类型,/Params 字典带大小、修改日期和 MD5 校验和;构建一个同时有 /F 和 /UF 的 FileSpec,描述作 /Desc,加上关系名;然后把这个 FileSpec 登记两次——Catalog 的 /AF 数组和 EmbeddedFiles 名称树。两处登记都是必需的:只登记在一处的文件也许还能从查看器的附件列表打开,这正是肉眼抽查对归档几乎证明不了什么的原因
关系字符串必须是 Source、Data、Alternative、Supplement 或 Unspecified 之一;其他任何值在写出任何字节之前就抛异常,空 MIME 类型同理。profile 也会被检查:AddPDFAssociatedFile 接受 PDF/A-3、PDF/A-4e 和 PDF/A-4f,基础 '4' profile 会抛异常,因为它不容许关联文件。反方向,AddDocumentAttachment 在 PDF/A-3、4e 和 4f 下拒绝运行,因为普通附件缺少这些 profile 需要的 /AF 登记。针对 PDF/A-3 用 AddPDFA3AssociatedFile 写的代码照常编译、行为不变;AddPDFAssociatedFile 就是那个方法不挑 profile 的名字
两个扩展在其间划分剩余权限。'4F' 下,EndDoc 确保 Catalog 带 EmbeddedFiles 名称树,AddFileAttachmentAnnotation 被接受,基础 profile 和 4e 拒绝它。'4E' 下,Add3DAnnotation 和 AddRichMediaAnnotation 被允许,SetOCGState 与 GoTo3DView 动作也是。其他所有 PDF/A profile 一律拒绝这四项
带命名空间的自定义角色为什么还是过不了 PDF/UA-2?
自定义角色通过 PDF/UA-2,只有在两个条件同时成立时:它的 /NS 条目指向结构树根中列出的命名空间字典,并且该角色经这个命名空间的 RoleMapNS 解析到标准命名空间里的角色。HotPDF 把这项工作分给三个方法:RegisterStructureNamespace 创建一个间接的 /Type /Namespace 字典,加进 StructTreeRoot /Namespaces 并返回它;用同一个 URI 再次调用返回既有字典而不是再加一个。AddStructureElementNS 创建元素并设置它的 /NS,顺路注册命名空间。AddStructRoleMapNS 写映射
const
PDF2NS = 'http://iso.org/pdf2/ssn';
WidgetNS = 'https://example.com/ns/widgets';
var
DocElem, Group: THPDFDictionaryObject;
begin
Pdf.PDFUACompliance := True;
Pdf.PDFUAPart := 2; // PDF/UA-2 要求 PDF 2.0
Pdf.Title := 'Widget catalog'; // Title 为空会在 EndDoc 抛异常
Pdf.Lang := 'en-US';
Pdf.BeginDoc;
DocElem := Pdf.AddStructureElement('Document', nil); // 绑定到 PDF2NS
Group := Pdf.AddStructureElementNS('WidgetGroup', WidgetNS, DocElem);
// 没有这条映射,EndDoc 会抛 ua2.rolemap.unresolved
Pdf.AddStructRoleMapNS('WidgetGroup', WidgetNS, 'Div', PDF2NS);
Pdf.CurrentPage.SetFont('Arial', [], 18);
Pdf.CurrentPage.BeginMarkedContentMCID('H1', 0);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Widgets');
Pdf.CurrentPage.EndMarkedContent;
Pdf.AddStructureElement('H1', Group, 0, 0); // 页面索引 0,MCID 0
Pdf.EndDoc;
end;
其中一些登记不需要你开口。设了 PDFUAPart 为 2 后,AddStructureElement('Document', nil) 把元素绑到 PDF 2.0 标准命名空间 http://iso.org/pdf2/ssn,因为 PDF/UA-2 树的根必须是那个命名空间里的 Document。PDF 2.0 词表里的每个角色都发生同样的绑定,所以 H1 和 P 不需要命名空间参数。只存在于 PDF 1.7 集合中的角色——如 Note、Code 或 BlockQuote——留在 1.7 命名空间
映射目标是一个真实的选择。给 AddStructRoleMapNS 传空目标命名空间写出短形式——一个对着 PDF 1.7 集合解析的裸角色名;传 PDF 2.0 URI 则写出两元素的 [角色 命名空间] 形式。在 PDF/UA-2 文档里,映射到 PDF 2.0 命名空间:整棵树随后解析进一个词表,Aside 或 FENote 这类没有 1.7 对应物的目标也可用了。两种映射形状在 EndDoc 直接拒绝:回环到自身的链,以及显式命名空间里的角色映射到同一命名空间里的另一个角色
调用顺序比看起来宽松。映射可以在元素存在之前或之后添加,因为树完成之前没有任何检查。PDFUAPart 为 1 时调用 AddStructureElementNS 会立刻失败:part 1 没有命名空间,这个调用当场抛"structure namespaces require PDFUAPart=2"
EndDoc 在写出文件之前检查什么
PDFUACompliance 开启且 PDFUAPart 为 2 时,EndDoc 在序列化任何内容之前运行完整的 PDF/UA-2 结构校验,第一个错误即成为异常。其中四项检查守着树的骨架:根必须恰好持有一个顶层 Document 元素;每个命名空间字典必须是间接对象、类型为 Namespace、携带只出现一次的非空 URI;每个 /NS 引用必须解析到根 /Namespaces 数组里列出的字典;每个角色必须直接或经 RoleMap 与 RoleMapNS 解析到标准命名空间。从 Document 元素到达不了的元素也会被报告
同一趟检查还覆盖文档级要求:非空且有效的 Lang;MarkInfo /Marked 设为 true;DisplayDocTitle 开启;XMP 包有非空 dc:title 和 pdfuaid:part 2。PDF/UA 一开,HotPDF 自己设置已标记标志、查看器偏好和 XMP 标识,并在 part 编号旁写出 pdfuaid:rev 2024。标题和语言由你提供,任一为空都在校验器启动之前抛异常。标题、列表、表格、注释和表单在同一次运行中检查
等到 EndDoc 是刻意的。生成进行到一半时,树合法地处于不完整状态:Document 元素可能还没有孩子,命名空间可能先于任何元素使用它而注册,映射可能晚于它的元素到达。每次调用都检查会拒绝这些状态。到了 EndDoc,树已完成且仍在内存里,异常给出规则代码和破坏它的元素路径
try
Pdf.EndDoc;
except
on E: Exception do
begin
// E.Message 的形状是
// PDF/UA-2 validation failed [ua2.heading.skipped at ...]:
// Heading level skips from H2 to H4
LogBuildFailure(Pdf.FileName, E.Message); // 你自己的日志
raise;
end;
end;
PDF/A-4 输出从自己的校验器得到同样的待遇。PDFACompliance 持有 part 4 值时,EndDoc 对照 ISO 19005-4 检查生成的对象图,带着规则代码和条款抛异常,比如没有有效关系的 FileSpec 报 pdfa4.filespec.relationship,混进来的 Info 键报 pdfa4.trailer.info_entries。Enterprise 版把两个校验器暴露为 ValidatePDFUA2Structure 和 ValidatePDFA4,它们把每一条保留的诊断填进问题数组而不是停在第一个错误。对应该打印完整清单的 CI 步骤,那是更好的入口
PDF 2.0 角色与 H6 之后的标题
带类型的 THPDFStandardStructureType 枚举增加 sstDocumentFragment、sstAside、sstTitle、sstFENote、sstSub、sstEm、sstStrong 和 sstArtifact。其中三个改变了普通商务文档打标签的方式:Aside 给侧栏和引文摘录一个不再误用 Sect 的家;FENote 把脚注和尾注标成注释,屏幕阅读器可以按需提供它们,而不是在句子中间读出来;Em 和 Strong 把强调作为结构携带,PDF 1.7 的树只能用 Span 包着它
标题在 PDF 2.0 里是开放的。枚举停在 sstH6,但 AddStructureElement 的字符串重载接受 H7、H8 甚至更深,因为 PDF 2.0 集合为从 1 起的任何 n 定义了 Hn。PDF 1.7 停在 H6,逼得深层技术文档要么压扁大纲、要么复用层级。对标准文档、法典和零件目录来说,仅这一条就足以成为把输出迁到 PDF 2.0 的理由
更深的标题带来更严的规则。EndDoc 里的 PDF/UA-2 一趟要求标题层级连续:第一个标题必须是 H1,从 H2 跳到 H4 会带着 ua2.heading.skipped 抛异常。层级只有在一个 DocumentFragment 里才允许更低的起点——这个角色就是为从更大文档里摘录的内容准备的。把 Markdown 或 HTML 大纲直接映射到 Hn 角色的生成器,会继承作者为了视觉大小跳过的每一级,所以打标签之前先规整大纲
把生产输出切到 PDF 2.0 之前
PDF 2.0 从头部改动开始,而头部正是老消费端最先读的东西。只接受到 PDF 1.7 为止的入库服务、打印工作流或业务线查看器,会在 %PDF-2.0 处停下,不管文件的其余部分多干净。选择任何 PDF/A-4 profile 或 PDF/UA part 2 就选择了 PDF 2.0,无论你要没要求,所以切换前确认每一个消费系统,而不是切换后
风险最低的铺开方式:发给你不控制的阅读者的文档保留 PDF/A-3;你掌握入库一侧的内部归档用 PDF/A-4f;可访问性策略点名的地方采用 PDF/UA-2。StrictVersionLock 在过渡期兼任护栏:把必须留在 1.7 的代码路径里设 Version 为 pdf17 并打开锁,一次意外的 PDF/A-4 选择会在 EndDoc 带着消息失败,而不是流进一个打不开文件的系统。PDF/A、PDF/X 与 PDF/UA 校验和 PDF/A-3 上的 ZUGFeRD 与 Factur-X 混合发票指南覆盖版本号之前那些 profile 决策,自动化预检报告笔记展示如何把判定变成构建步骤
这一切作为原生 VCL 代码运行在 Delphi 和 C++Builder 组件内部,PDF/A-4 和 PDF/UA-2 输出随你的可执行文件发行,不需要外部引擎。支持的 profile 与 RAD Studio 版本列在 HotPDF Delphi 组件页面