您有来自十几个不同生成器的上万份合同 PDF,法律部门希望每一份都携带正确的 Author、修正后的 Producer 字符串,以及在启动时打开书签面板的阅读模式;天真的修复方法是加载每个文件、重新布置页面并写入一个新鲜的文档;这样做您就扔掉了每个现有的对象编号、增量更新历史记录、任何数字签名以及原始工具输出的精心调整的 xref;页面看起来是一模一样的,但在结构上文件成了陌生人;对于元数据编辑来说,这完全是错误的交易
正确的做法是将加载的文档视为在内存中就地变异的对象图:伸入 Info 字典、 /Metadata 流和 Catalog 中,更改您关心的少数条目,然后将结果写回;HotPDF 是适用于 Delphi 和 C++Builder 的原生 VCL PDF 组件,它正是通过其已加载文档的写入 API 暴露了该表面;本文介绍如何正确使用它,以及几乎每个人都会犯的一个错误:编辑了 Info 字典而忘记了同一份元数据的第二个副本活在 XMP 中
两个存储同一份元数据的地方,以及它们的冲突
PDF 在两个平行的位置携带文档信息,这是大多数“我更改了标题但 Acrobat 仍显示旧标题”反馈的根源;第一个是文档信息字典,即经典的 /Info 对象,包含 /Title、/Author、/Subject、/Keywords、/Creator 和 /Producer 键,定义在 ISO 32000-1 §14.3.3 中;第二个是 XMP 数据包,这是一个 XML 文档,作为 Catalog 下悬挂的流存储在 /Metadata 下,定义在 §14.3.2 中,建立在 Adobe XMP数据模型之上
两者都可以持有标题,规范中没有任何内容强求它们一致;现代查看器和大多数 PDF/A 验证器在 XMP 数据包存在时更倾向于它,并在其不存在时回退到 Info 字典;因此,如果您只更新了 /Info —— 这正是绝大多数“设置 PDF 元数据”代码所做的 —— 信任 XMP 的阅读器将继续显示陈旧的值,而 PDF/A 检查器会标记该不匹配;在任何已经有 XMP 数据包的文件上,正确的操作是双重写入:更改 Info 条目并重新生成 XMP,使两者保持一致;HotPDF 提供了这两半部分,将它们一起使用的纪律则在于您
编辑 Info 字典
Info 侧的辅助函数是稀疏且可预测的;SetLoadedTitle、SetLoadedAuthor、SetLoadedSubject、SetLoadedKeywords、SetLoadedCreator 和 SetLoadedProducer 各自接受单个 AnsiString,并将相应的键写入加载的 Info 字典中,如果键存在则替换该值,如果不存在则添加它;要完全剥离一个键 —— 比如命名了您内部工具的泄露的 /Creator —— 请使用裸键名称调用 RemoveLoadedInfoKey;这些操作均不触及 XMP,它们纯粹操作于 LoadFromFile 在解析文件时定位 of /Info 对象
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
begin
Pdf.SetLoadedTitle('Master Services Agreement 2026');
Pdf.SetLoadedAuthor('Legal Department');
Pdf.SetLoadedSubject('Executed contract, retention 7 years');
Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
Pdf.SetLoadedProducer('Acme Document Pipeline');
Pdf.RemoveLoadedInfoKey('Creator'); // drop the originating tool name
Pdf.SaveLoadedDocument('contract-out.pdf');
end;
finally
Pdf.Free;
end;
end;
有一个保持诚实的细节:这些函数接受 AnsiString;对于 ASCII 标题,这不是个问题,但需要非拉丁字符的 PDF 文本字符串在移交前必须按照规范的要求进行编码 —— 带有字节顺序标记的 UTF-16BE 或 PDFDocEncoding;库将您给它的字节写入字符串对象中,它不会为您猜测编码;如果您的标题是纯英文,可以忽略此条;如果它们携带重音或中日韩(CJK)字符,请刻意进行编码并在实际的查看器中进行测试
重写 XMP 数据包
SetLoadedXMPMetadata 是双重写入的另一半;将完整的 XMP 数据包作为 AnsiString 传递给它,它会做两件事之一:如果 Catalog 已经引用了 /Metadata 流,它会就地替换该流的内容,保持相同的对象编号;如果没有元数据流,它会创建一个,将其标记为 /Type /Metadata 和 /Subtype /XML,分配一个对象编号并从 Catalog 中进行链接;无论哪种方式,您都会得到一个查看器可以读取的有效元数据对象
您提供 XML,这意味着您控制了 Schema —— dc:title、dc:creator、xmp:CreatorTool 等等;这是权力和责任的结合:库不会解析或验证您的数据包,并且它以未压缩的形式写入字节,未应用任何流过滤器;畸形的数据包会通过调用完成,并稍后作为损坏的元数据投诉而浮出水面;请仔细构建 XML,并精确镜像您写入 Info 字典中的值,使两个视图永不相互矛盾
const
XMP_TEMPLATE =
'' +
'' +
'' +
'' +
'%s ' +
'%s ' +
' ';
begin
// After setting the Info dictionary, mirror the same values into XMP:
Pdf.SetLoadedTitle('Master Services Agreement 2026');
Pdf.SetLoadedAuthor('Legal Department');
Pdf.SetLoadedXMPMetadata(
AnsiString(Format(XMP_TEMPLATE,
['Master Services Agreement 2026', 'Legal Department'])));
Pdf.SaveLoadedDocument('contract-out.pdf');
end;
该顺序 —— Info First、XMP Second,然后保存 —— 是需要内化的模式;这两个调用是独立的,它们之间的一致性仅仅是因为您给它们传递了相同的字符串;如果您在已含有 XMP 数据包的文件上略去 XMP 调用,那么您又回到了本节一直要防范的静默失效 bug

引导查看器如何打开文件
三个 Catalog 条目决定了读者在文档打开瞬间看到的内容,而这三者都是对已加载图的单行编辑;SetLoadedPageMode 将 /PageMode 写入为名称对象:传递 'UseOutlines' 以弹出书签面板,'UseThumbs' 用于缩略图栏,'FullScreen' 用于演示模式,或 'UseAttachments' 以显示附件窗格(ISO 32000-1 §7.7.3.1,表 28);SetLoadedPageLayout 以相同方式写入 /PageLayout —— 'SinglePage'、'OneColumn'、'TwoColumnLeft' 及其余;两者都接受不带前导斜杠的名称,库在输出时会添加它
SetLoadedLanguage 写入 Catalog 的 /Lang 条目,这是文档整体的自然语言标签 —— 'en-US'、'de-DE' 等 BCP 47 标签;注意让人摔跟头的类型差异:/PageMode 和 /PageLayout 是 PDF 名称对象,而 /Lang 是字符串;HotPDF 在内部正确处理了这一点,但如果您查看输出,会看到 /PageMode /UseOutlines 对应于 /Lang (en-US),现在您明白原因了;/Lang 条目的重要性比看起来更大:它是辅助技术读取以选择发音的依据,也是 PDF/UA 可访问性一致性的硬性要求
if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
Pdf.SetLoadedPageMode('UseOutlines'); // /PageMode, a name
Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
Pdf.SetLoadedLanguage('en-US'); // /Lang, a string
Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;
在不扰乱树的情况下重命名书签
书签标题是常规清理内容 —— 比如标题中的错别字、构建提纲后章节重新编号;SetLoadedOutlineTitle 接受基于顶级提纲条目的基于零的索引和新标题,遍历 Catalog → /Outlines → /First → /Next 链到该位置,并替换该条目的 /Title 字符串;它只改变标题,目标、打开/折叠状态以及子结构均不予触及
if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
Pdf.SaveLoadedDocument('report-renamed.pdf');
end;
重命名之所以安全,正是因为它从不触及结构性计数器;删除提纲条目是一个让人抓狂的情况,即使您只是重命名,也值得了解它,因为它告诉您什么不应该去手工编辑;每个提纲节点都带有一个 /Count,根据 ISO 32000-1 §12.3.3,该计数并不是直接子节点的数量;它是各个级别上可见后代的总数:正的 /Count N 表示当前展现了 N 个后代,而负值表示节点有后代并已折叠;当删除顶级条目时,/Outlines 根计数不能简单地减去 1,它必须重新计算:通过在每个幸存的顶级节点上累加“节点本身加上其正的 /Count”,跳过任何已折叠(负计数)节点的后代;如果搞错了这一点,读者显示的书签总数就会发生漂移 —— 每次删除会跳动不止 1;重命名避开了所有这些,这也是宁可选择针对性辅助函数也不要自己戳字典的另一个原因
就地保存如何起作用
上述每个编辑都会修改内存中的对象,在运行 SaveLoadedDocument 之前没有任何内容到达磁盘;这种方法之所以廉价,是因为保存操作并不重新生成文档 —— 它保留了现有的对象编号以及 HotPDF 在加载时解析的结构,写回相同的图加上您少数更改过的和新分配的对象;这正是使元数据处理免于重写整个文件的原因,也是使对象流和增量更新工作的相同就地更新机制;如果您的源文件来自 Word 或其他 Office 套件,在编辑它们之前,它们的对象布局有一些值得了解的怪癖;关于 Office PDF 中混合引用交叉引用流的文章介绍了这些文件是如何结构的以及在往返处理后能存活什么内容
有两个需要遵守的边界:首先,这是一个就地编辑模型,而不是一个脱敏或清理工具:删除 Info 键只会删除该键,但它不会擦除可能保留在同一文件先前增量更新代中的旧值;如果您的要求是真正清除敏感元数据,那是一项不同且更重的操作;其次,XMP 写入是字面上的 —— 库信任您的 XML 并且不验证它 —— 因此对于任何用于 PDF/A 或严格验证器的内容,请从已知良好的模板生成数据包并验证输出;在这些限制内使用,就地元数据编辑是一个大小合适的工具:它修复了少数错误的字节,而把原作者写入的 99% 的已经是正确的文件内容保持原样
这里展示的加载文档写入 API 随标准的适用于 Delphi 和 C++Builder 的 HotPDF Component 一起提供,同时也提供了整套元数据、提纲和 Catalog 的编辑方法