你的检测代码报告另一个工具生产的 PDF/UA 文件什么都没声明,而一份 PDF/R 扫描件在编辑器给它贴上便签的那一刻就不再是 PDF/R。HotPDF 通过 IsLoadedPDFUA、GetLoadedPDFR1Version 与 GetLoadedPDFDeclarations 这类访问器从已加载文档读取这些声明,每个访问器都到自己那个标准存放标识符的位置去找
各 PDF 标准把标识符写在哪里
各子标准从未就声明位置达成一致。大多数用 catalog /Metadata 流里的 XMP 属性,少数老标准仍靠文档信息字典里的键,PDF/R 把一行注释放在文件尾部旁边,PDF 2.0 一代又添两种机制:catalog 里的开发者扩展字典与 XMP 里的 PDF Declarations。标识符是文件关于自身的陈述;内容是否配得上它,由校验器说了算,而生成符合规范的 PDF/A、PDF/X 与 PDF/UA 输出是另一个话题。做路由(PDF/X 进打印队列、PDF/A 进归档、WTPDF 进无障碍流水线)时你要的正是声明本身,从正确的位置读出来:
| 标准 | 声明存放在哪 | HotPDF 访问器 |
|---|---|---|
| PDF/A (ISO 19005) | XMP pdfaid:part 与 pdfaid:conformance,PDF/A-4 另有 pdfaid:rev | IsLoadedPDFA、GetLoadedPDFAVersion、GetLoadedPDFARevision |
| PDF/X (ISO 15930) | PDF/X-1a 与 PDF/X-3 放在信息字典的 GTS_PDFXVersion,更晚的部分放 XMP | IsLoadedPDFX、GetLoadedPDFXVersion |
| PDF/UA (ISO 14289) | XMP pdfuaid:part | IsLoadedPDFUA、GetLoadedPDFUAVersion |
| PDF/VT (ISO 16612-2) | XMP pdfvtid:GTS_PDFVTVersion | IsLoadedPDFVT、GetLoadedPDFVTVersion |
| PDF/E (ISO 24517-1) | ISO_PDFEVersion,从信息字典、catalog 或 XMP 读取 | IsLoadedPDFE、GetLoadedPDFEVersion |
| PDF/R-1 (ISO 23504-1) | %PDF-raster-x.y 注释行,紧挨最后一个 startxref 上方 | IsLoadedPDFR1、GetLoadedPDFR1Version |
| PDF/VCR-1 (ISO 16613-1) | catalog 或 XMP 里的 GTS_PDFVCRVersion | IsLoadedPDFVCR、GetLoadedPDFVCRVersion |
| WTPDF 1.0, ISO/TS 32005 | XMP 中的 pdfd:declarations 条目 | IsLoadedWTPDF、GetLoadedWTPDFVersion、IsLoadedISO32005 |
| PDF 2.0 开发者扩展 | catalog 里的 /Extensions /ISO_ | HasLoadedISODeveloperExtension、GetLoadedISODeveloperExtensionLevels |
uses
System.SysUtils, HPDFDoc;
procedure ReportClaims(const FileName: string; const Password: AnsiString);
var
Pdf: THotPDF;
PDFX, PDFXLevel: AnsiString;
begin
Pdf := THotPDF.Create(nil); // 专用实例,只用于检测
try
// 密码重载会解密各流,XMP 包也不例外;
// 无需密码即可打开的文件传 ''
if Pdf.LoadFromFile(FileName, Password) <= 0 then
raise Exception.CreateFmt('Cannot open %s', [FileName]);
if Pdf.IsLoadedPDFA then
Writeln('PDF/A: ', Pdf.GetLoadedPDFAVersion); // 例如 PDF/A-2b
PDFX := Pdf.GetLoadedPDFXVersion; // 先 XMP,再 Info
if PDFX <> '' then
begin
PDFXLevel := Pdf.GetLoadedInfoKeyValue('GTS_PDFXConformance');
if PDFXLevel <> '' then
Writeln('PDF/X: ', PDFX, ', conformance ', PDFXLevel)
else
Writeln('PDF/X: ', PDFX);
end;
if Pdf.IsLoadedPDFUA then
Writeln('PDF/UA: ', Pdf.GetLoadedPDFUAVersion); // 例如 PDF/UA-1
if Pdf.IsLoadedPDFVT then
Writeln('PDF/VT: ', Pdf.GetLoadedPDFVTVersion);
if Pdf.IsLoadedPDFE then
Writeln('PDF/E: ', Pdf.GetLoadedPDFEVersion);
if Pdf.IsLoadedPDFR1 then
Writeln('PDF/R: ', Pdf.GetLoadedPDFR1Version); // 例如 PDF/R-1.0
if Pdf.IsLoadedWTPDF then
Writeln('WTPDF: ', Pdf.GetLoadedWTPDFVersion);
finally
Pdf.Free;
end;
end;
检测请像示例那样用单独的 THotPDF 实例。IsLoaded… 系列函数对组件当前持有的任何文档作答,包括它正在构建的文档:PDFUACompliance 为输出打开之后 IsLoadedPDFUA 也返回 True,IsLoadedPDFX 与非空的 PDFXCompliance 一致。构建中途这样查很顺手,但在一个兼任两职的复用组件上,它会把你的输出设置混进对别人文件的判定
PDF/X 是唯一一个标识符在部分之间搬过家的家族。PDF/X-1a 与 PDF/X-3 是 PDF 1.3 子集,把 GTS_PDFXVersion 放进文档信息字典、完全不需要 XMP;PDF/X-4 及更晚部分则要求 XMP 属性 pdfx:GTS_PDFXVersion。GetLoadedPDFXVersion 先查 XMP——pdfx 或 pdfxid 前缀都认——再查信息字典,再查一些老生产商写入的 catalog 键,并原样返回声明的值。PDF/X-1a 有一处值得写进代码的细节:其版本值是 PDF/X-1:2001,而 PDF/X-1a:2001 合规级别住在自己的键 /GTS_PDFXConformance 里,示例需要区分 X-1a 与纯 X-1 时就用 GetLoadedInfoKeyValue 读它
合法的 XMP 声明长什么样
XMP 是以 XML 序列化的 RDF,而 RDF/XML 允许一个简单属性在 rdf:Description 里以两种写法出现:作子元素 <pdfuaid:part>1</pdfuaid:part>,或作描述自身的属性 pdfuaid:part="1",单引号双引号皆可。ISO 16684-1 两种都接受,一个包可以把属性拆到多个 rdf:Description 元素里。这种拆分并非纸上谈兵。HotPDF 为 PDF/UA、PDF/VT、PDF/E 与 PDF/X 写子元素形式,而 veraPDF 测试语料和许多外部生产商写简写形式,用 pdfuaid:part="1" 或 pdfvtid:GTS_PDFVTVersion="PDF/VT-1" 这类描述把整份声明装进属性。围绕 Pos('<pdfuaid:part>', Xmp) 构建、只拿自家输出测过的检测器,本地检查全过,碰上第一个 veraPDF 样本就判它无声明。HotPDF 的 XMP 读取器对表中每个访问器都两种形式通吃。GetLoadedPDFAVersion 把 pdfaid:part 与 pdfaid:conformance 合成 PDF/A-1b、PDF/A-2u、PDF/A-4 或 PDF/A-4f 这类标识符;GetLoadedPDFARevision 返回 pdfaid:rev——PDF/A-4 要求的版本年(2020),第 1 至 3 部分未定义:
procedure ReportPDFALevel(Pdf: THotPDF);
var
Level, Revision: AnsiString;
begin
Level := Pdf.GetLoadedPDFAVersion; // 'PDF/A-1b'、'PDF/A-2u'、'PDF/A-4f' 等
if Level = '' then
Exit;
Revision := Pdf.GetLoadedPDFARevision; // PDF/A-4 为 '2020',第 1-3 部分为空
if Revision <> '' then
Writeln(Level, ', edition ', Revision)
else
Writeln(Level);
// 预期结果:无论生产商写的是
// <pdfaid:part>2</pdfaid:part> 还是 pdfaid:part="2"
end;
前缀是另一个会动的部件。XML 里前缀只是本地别名;标识 PDF/A 模式的是命名空间 URI http://www.aiim.org/pdfa/ns/id/——已批准的 ISO 19005-4 勘误也接受它的 https:// 拼法——生产商可以把它绑到任何前缀上。GetLoadedPDFAVersion 在读 part 与级别之前,先查包为该 URI 声明的前缀,所以写 pdfa:part 而非 pdfaid:part 的包照样能识别。你自己从 GetLoadedXMPMetadata 返回的已解码包里读其他任何 XMP 属性时,也值得同样小心:两种序列化、从 xmlns 声明解析前缀,以及等号两侧允许的空白
加密文件让字节搜索找不到声明
直接在原始文件里扫 pdfaid 或 ISO_PDFEVersion 省了解析,却栽在两个寻常场景上。多数 XMP 流是 Flate 压缩的,属性名在磁盘上从不以文本形式出现。而加密文档里元数据流是密文:ISO 32000-2 §7.6.4.2 定义了 EncryptMetadata,默认值为 true,所以除非生产商明确选择退出,包会与其他所有流一起加密。于是对加密的 PDF/E-1 文件做字节搜索,明明有 ISO_PDFEVersion 声明却报缺失。可靠的顺序是加载、解密、解码、再搜索。通过密码重载 LoadFromFile(FileName, Password) 打开文件;只带所有者密码、在任何查看器里都无提示打开的文件传空字符串。单参数的 LoadFromFile(FileName) 只加载文档结构、不解密流,XMP 包因此不可读;走密码重载之后,GetLoadedXMPMetadata 与上面每个访问器都能在解密解压后的包上工作
PDF/R 文件为何编辑一次就丢了身份
PDF/R-1 的自我标识方式独一份。ISO 23504-1 第 5 章要求在持有 startxref 的最后一行的紧上一行放一条注释行,内容为 %PDF-raster-1.0(版本按实际情况)。PDF 解析器读交叉引用数据时会把注释扔掉,对象模型无从报告;证据只存在于文件末尾的原始字节里。GetLoadedPDFR1Version 回到文档加载时的源文件,最多读末尾 64 KiB,找到最后一个 startxref 并检查其上一行。接下来做一次增量更新试试。添加注释的编辑器会在原始 %%EOF 之后追加新对象、新的交叉引用部分、新的尾部和新的 startxref。%PDF-raster-1.0 行还在文件里,但它现在位于倒数第二个 startxref 上方,而标准对哪个才算数毫不含糊。预期结果:编辑之后 IsLoadedPDFR1 返回 False,除非编辑工具在自己的更新里重发了这条注释。在整个文件里扫 %PDF-raster- 的检测器会说有,按第 5 章的规则这个答案是错的。必须保住 PDF/R 身份的采集流程,会把注释放进 sidecar 文件,或交给把整个文件重写为 PDF/R 的工具
开发者扩展:整数级别、名称与数组
PDF 2.0 文档通过 catalog 的 /Extensions 字典声明其依赖的 ISO 技术规范(ISO/TS 32001 的 SHA-3 哈希、ISO/TS 32002 的 ECC 签名、ISO/TS 32007 的 glTF 资产),该字典定义于 ISO 32000-2 §7.12。在注册前缀 /ISO_ 之下,表 48 允许单个开发者扩展字典,PDF 2.0 起还允许它们组成的数组;表 49 连同 §7.12.5 把 /ExtensionLevel 定为整数。只处理一种容器形态的阅读器会错过按另一种形态写的每个文件。HasLoadedISODeveloperExtension 与 GetLoadedISODeveloperExtensionLevels 两种都收,途中还会解析间接引用,尽管 §7.12.1 说这些字典必须是直接对象:
procedure ReportISOExtensions(Pdf: THotPDF);
var
Levels: THPDFIntegerArray;
Revision, URL: AnsiString;
I: Integer;
begin
Levels := Pdf.GetLoadedISODeveloperExtensionLevels;
for I := 0 to High(Levels) do
Writeln('ISO_ extension level ', Levels[I]);
if Pdf.HasLoadedISODeveloperExtension(32001, Revision, URL) then
Writeln('ISO/TS 32001 revision ', Revision, ', documented at ', URL);
// 对用 EnsureISO32001Extensions 写出的文件,预期为:
// ISO/TS 32001 revision :2022, documented at https://www.iso.org/standard/45874.html
if Pdf.HasLoadedISODeveloperExtension(32007) then
Writeln('glTF 3D assets per ISO/TS 32007');
end;
ISO/TS 32007:2024 第一版在 §4.8 有个示例把 /ExtensionLevel /32007 写成名称;PDF Errata Issue #835 把它纠正为整数 32007,但照抄原始示例的生产商仍写名称。因此 HotPDF 读取器在整数查询落空时,退而解析带或不带前导斜杠的名称或字符串值,HasLoadedISODeveloperExtension(32007) 两种拼法都找得到。另一处勘误关乎 /URL:ISO 32000-2:2020 的表 49 把 /URL 列为可选,Issue #732 将它提升为必填。写入侧 HotPDF 在每次 EnsureISO…Extensions 调用中都发出整数形式和一个 /URL;读取侧,对按 2020 文本写出的文件,URL out 参数为空属正常,把它当作参考信息,绝不要当作拒绝扩展的理由
生产商写的是哪个 WTPDF URI
WTPDF 1.0 与 ISO/TS 32005 声明是 PDF Declarations:XMP 里 pdfd:declarations bag 中的条目,命名空间是 PDF Association 的 PDF Declarations 技术笔记定义的 http://pdfa.org/declarations/。每个条目带一个 conformsTo URI 和可选的 claimBy、claimDate、claimCredentials 与 claimReport 字段,GetLoadedPDFDeclarations 把它们作为 THPDFDeclarationEntry 记录数组返回;写入侧,AddPDFDeclaration 把同样五个字段放进 XMP 包,紧挨着PDF 2.0、PDF/A-4 与 PDF/UA-2 标识命名空间。用纯文本搜索把 bag 切成条目另有暗坑:按 conformsTo 的每次出现切开,子元素形式的闭合标签 </pdfd:conformsTo> 会被当成新条目的开头,第一条声明在自己的结尾之前就被截断。HotPDF 读取器会跨过闭合标签,两种序列化都收
URI 本身是更难缠的部分。同一份声明可能以 http:// 或 https:// 到你手里,WTPDF 1.0 Issue #395 又改动了 # 之前要不要放一个 /,勘误前后写出的文件就差这一个字符;纯字符串比较会把它们当成两个标准。HasLoadedPDFDeclaration 比较 URI 不分大小写,把 https:// 归一为 http://,并在两边都丢掉紧挨 # 之前的 /,所以下面的查询无论生产商选了哪种拼法都能匹配。片段保持原样:#accessibility1.0 与 #accessibility1.0-validated 是两个不同的声明,后者额外断言有校验器检查过文件,你的工作流关心哪个就该要哪个。GetLoadedWTPDFVersion 把两者折叠成 'WTPDF 1.0 Accessibility' 或 'WTPDF 1.0 Reuse' 供路由使用
procedure ReportDeclarations(Pdf: THotPDF);
var
Decls: THPDFDeclarationEntryArray;
I: Integer;
begin
Decls := Pdf.GetLoadedPDFDeclarations;
for I := 0 to High(Decls) do
Writeln(Decls[I].ConformsTo, ' claimed by ', Decls[I].ClaimBy,
' on ', Decls[I].ClaimDate);
// 匹配 http 或 https,'#' 之前有没有斜杠均可
if Pdf.HasLoadedPDFDeclaration(
'https://pdfa.org/declarations/wtpdf#accessibility1.0') then
Writeln('WTPDF 1.0 Accessibility (unvalidated claim)');
// 覆盖 Reuse 与 Accessibility 的普通及 -validated 变体
Writeln('WTPDF profile: ', Pdf.GetLoadedWTPDFVersion);
end;
对采集或打印路由,操作顺序比任何单个访问器都重要:趁文件刚到手、注释或盖章步骤还没追加更新之前测 IsLoadedPDFR1;PDF/X 路由交给 GetLoadedPDFXVersion,它同时覆盖 PDF/X-1a 与 PDF/X-3 作业的信息字典和 PDF/X-4 作业的 XMP,只在必须区分 X-1a 与纯 X-1 的时候才读 /GTS_PDFXConformance。两项检查对加载进 HotPDF Delphi PDF component 的任何文档都适用,包括用密码打开的加密文件