技术文章

PDF 元数据、大纲书签与注解结构详解

同一份 PDF 能报出两个不同的作者。桌面阅读器的属性对话框显示一个名字,索引 XMP 的资产管理系统显示另一个,而两个程序都没读错。PDF 把文档元数据存了两份,又把书签、链接和便签放在从不触碰页面内容的结构里,所以它们任何一个出错时,每一页都渲染得完美无缺

底下的对象模型(间接对象、交叉引用表、trailer)在PDF 文档结构详解里讲过。这里只谈挂在文档目录上的东西

目录里的四个条目,四个独立的子系统

下面谈到的一切都从文档目录抵达——trailer 的 /Root 条目指向的那个字典(ISO 32000-1 §7.7.2)。一个把它们全部接上的目录长这样:

1 0 obj
<< /Type /Catalog
   /Pages 2 0 R
   /Outlines 3 0 R
   /Names << /EmbeddedFiles 4 0 R /Dests 6 0 R >>
   /Metadata 5 0 R
>>
endobj
PDF 示意图:PDF 文档目录连向页面树、大纲根、名称字典和 XMP 元数据流四个可选子系统
文档目录把四个互相独立的子系统接在一起,每个都通过自己的条目抵达。其中任何一个缺席,页面依然照常渲染

/Pages 是可见的文档,也是四个条目里目录唯一要求的。/Outlines 通向书签树,/Metadata 通向 XMP 流,/Names 通向名称字典——嵌入文件住在 /EmbeddedFiles 下,命名目标住在 /Dests 下。三个可选项一个都找不到的阅读器照样显示每一页,这正是文件经过只懂页面的工具之后,这一层最先退化的原因。页面对象内部没有任何东西指回大纲、/Dests 树或 XMP 流。只把页面复制进新文件,三样都留在身后

PDF 为什么带着两套元数据存储?

PDF 带着两套元数据存储,是因为格式长出了第二套而没有退役第一套。最早的机制是文档信息字典(ISO 32000-1 §14.3.3,表 317),由 trailer 的 /Info 引用:/Title、/Author、/Subject、/Keywords、/Creator、/Producer 的扁平文本条目,外加 /CreationDate、/ModDate 和 /Trapped。ISO 32000-2 把整个字典弃用(两个日期条目除外),让写入方改用 XMP 元数据流

PDF 示意图:对比 PDF Info 字典与 XMP 元数据流两套存储,它们携带重叠的字段并必须保持同步,以 XMP 为准
Info 字典和 XMP 流并行回答同样的问题。让它们保持同步的办法是从同一组取值重新生成两者

XMP(ISO 32000-1 §14.3.2)是一份用 RDF 写成的 XML 文档,存在一个字典带 /Type /Metadata /Subtype /XML 的流里。与 Info 字典不同,XMP 包的设计目标就是让对 PDF 语法一无所知的工具也能找到并解析它:

5 0 obj
<< /Type /Metadata /Subtype /XML /Length 1235 >>
stream
<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
    <rdf:Description rdf:about=""
        xmlns:dc="http://purl.org/dc/elements/1.1/"
        xmlns:xmp="http://ns.adobe.com/xap/1.0/"
        xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
      <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report</rdf:li></rdf:Alt></dc:title>
      <dc:creator><rdf:Seq><rdf:li>A. Author</rdf:li></rdf:Seq></dc:creator>
      <xmp:CreateDate>2026-06-16T10:46:27+08:00</xmp:CreateDate>
      <xmp:CreatorTool>Reporting Service 4.2</xmp:CreatorTool>
      <pdf:Producer>losLab PDF Library</pdf:Producer>
    </rdf:Description>
  </rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
endstream
endobj

那段代码里有三个细节决定这个包能不能扛住真实工具。xpacket 处理指令为包划出边界,好让扫描器能在任意字节流里定位它;丢了收尾的 <?xpacket end="w"?>,文件照样打开,但扫描器丢了结束标记。值类型同样要紧:dc:title 是语言备选项、需要 rdf:Alt,dc:creator 是有序列表、需要 rdf:Seq;把任何一个写成裸文本节点都是容易犯的错。前缀却只是约定;解析器认的是命名空间 URI,所以绑到错误 URI 上的 dc: 是完全另一个属性

Info 键与它们的 XMP 对应项按下面这样配对。PDF/A-1(ISO 19005-1 §6.7.3)把配对写成合规规则:两套存储都在时,值必须等价

Info 字典XMP 属性
/Titledc:title (rdf:Alt)
/Authordc:creator (rdf:Seq)
/Subjectdc:description (rdf:Alt)
/Keywordspdf:Keywords
/Creatorxmp:CreatorTool
/Producerpdf:Producer
/CreationDate, /ModDatexmp:CreateDate, xmp:ModifyDate

手工编辑与合并是两套存储漂移的地方。一次合并可以从一个输入继承 /Info、从另一个继承 XMP 包。更稳妥的规则是把 XMP 当作事实来源,为老的消费方保留 /Info,任何一侧变化时都从同一组取值重新生成两者

从同一组属性写出两套存储

HotPDF 从同一份文档属性构建两套存储。Title、Author、Subject 和 Keywords 进 /Info;EnableXMPMetadata 为 True 时,EndDoc 也用同样这些值写一份 XMP 包——dc:title 作为 rdf:Alt、dc:creator 作为 rdf:Seq——并从目录的 /Metadata 链过去。EnableXMPMetadata 默认 False,凡是文件要离开你手的场景都值得打开:只有 /Info 的文件对每个只读 XMP 的工具不可见。需要额外 schema 时,CustomXMP 用你自己的文本替换生成的包

uses
  System.SysUtils, Vcl.Graphics, HPDFDoc;

procedure BuildReport(const FileName: string);
var
  PDF: THotPDF;
  Chapter: THPDFDocOutlineObject;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    PDF.FileName := FileName;
    // 同一组取值喂给 /Info 和 XMP 包
    PDF.Title := 'Quarterly Report';
    PDF.Author := 'A. Author';
    PDF.Subject := 'Results for the third quarter';
    PDF.Keywords := 'quarterly, results';
    PDF.EnableXMPMetadata := True;
    PDF.BeginDoc;

    PDF.CurrentPage.SetFont('Arial', [fsBold], 20);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Chapter 1: Results');
    Chapter := PDF.OutlineRoot.AddChild('Chapter 1: Results', 0, 50);
    Chapter.Opened := True;   // 存成正的 /Count

    PDF.AddPage;
    PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Introduction');
    Chapter.AddChild('Introduction', 0, 50);

    PDF.AddPage;
    PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Methodology');
    Chapter.AddChild('Methodology', 0, 50);

    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

编辑既有文件是另一回事。在已加载的文档上,SetLoadedTitle、SetLoadedAuthor、SetLoadedCreator 和它们的兄弟函数写 Info 字典条目,XMP 流则是你用 SetLoadedXMPMetadata 替换的另一个对象。用一个调用改了作者、另一个没改,SaveLoadedDocument 产出的就是开头那段里那个双作者文件

书签面板背后的大纲树

书签面板由一棵双向链接的字典树重建,即文档大纲(ISO 32000-1 §12.3.3,表 152 和 153)。文件里任何地方都不存在书签数组;阅读器跟着目录 /Outlines 的引用走,重建出面板

PDF 示意图:PDF 大纲树中的 Parent、First、Last、Prev、Next 和 Count 链接如何重建查看器的书签面板
书签面板是从一棵由 Parent、First、Last、Prev、Next 和 Count 串起来的双向链接大纲树重建出来的。一处过期链接就会悄无声息地藏起整个分支
8 0 obj                                    % the outline root
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % top-level: a chapter
<< /Title (Chapter 1: Results)
   /Parent 8 0 R /Count 2
   /First 12 0 R /Last 15 0 R
   /Dest [3 0 R /XYZ 0 792 0] >>
endobj
12 0 obj                                   % first child
<< /Title (Introduction)
   /Parent 9 0 R /Next 15 0 R
   /Dest [4 0 R /XYZ 72 720 0] >>
endobj
15 0 obj                                   % second child, last sibling
<< /Title (Methodology)
   /Parent 9 0 R /Prev 12 0 R
   /Dest [5 0 R /Fit] >>
endobj

不变量可以直接从链接上读出来。每个条目都指回自己的 /Parent。兄弟项经 /Prev 和 /Next 串链;第一个子项没有 /Prev,最后一个没有 /Next。父项经 /First 和 /Last 点名自己的两端,夹在中间的东西只作为兄弟遍历中的一步存在。断一条链接,阅读器显示它们还够得着的部分:过期的 /Next 把一章截短,与链尾不符的 /Last 让不同阅读器画出不同的树

/Count 让人意外,因为它存的是面板状态。在根上,它是所有层级可见条目的数量。在条目上,正值表示该项展开、值为可见后代数;负值表示折叠、绝对值为展开后会出现多少后代。样例里根是 3:一章加它两个展开的子项。把每个 /Count 都写成正数总计的生成器,会无声地重新展开作者折叠起来的每个分支

HotPDF 里,OutlineRoot 是一个 THPDFDocOutlineObject,AddChild(Title, X, Y) 追加条目、接好它的 /Parent、/Prev、/Next、/First 和 /Last 链接,并把它指到当前页的 [page /XYZ left top 0]。Opened 属性决定正负号,写出器在 EndDoc 时从完成的树重算每个 /Count,所以子项加完之后再设 Opened 也没问题。InsertBefore、InsertAfter 和 MoveTo 用同一套簿记重构树

目标位置:点击真正落到哪里

书签和链接注解都解析到一个目标位置(ISO 32000-1 §12.3.2,表 151),而目标位置不止一个页码。它是一个数组,先点名一个页面对象,再用一个取景类型告诉阅读器如何取景。主力是 [page /XYZ left top zoom]。三个操作数任何一个都可以是 null,意思是「保持阅读器原来的样子」,zoom 为 0 与 null 等效。所以 [page /XYZ null null null] 只换页面、别的不动,这通常正是「跳到某页」链接该做的事

Fit 家族用精确定位换韧性。[page /Fit] 把整页缩放进窗口,[page /FitH top] 以给定上边缘适配页宽,[page /FitR left bottom right top] 把矩形放大到填满视图。它们在点击时从页面几何算缩放;烘死了缩放值的 /XYZ 在页面被改尺寸后仍保持原缩放。对目录来说,用小节上边缘的 /FitH 是更好的默认

坐标是人们栽跟头的地方。目标位置的数字用默认用户空间,原点在左下、y 向上增长(§8.3.2.3),而屏幕排版习惯从顶部量。HotPDF 的画布风格调用(TextOut、链接矩形、AddChild 的 X 和 Y)接收自上而下坐标并替你转换。RegisterNamedDestination 把它的 Left 和 Top 原样写进目标数组,所以它们必须已经是 PDF 用户空间:

// 在 BeginDoc 与 EndDoc 之间调用,前提是页 0..2 已存在
procedure AddContentsLinks(PDF: THotPDF);
var
  R: TRect;
begin
  // 第 1 页顶部下方 50 pt 的命名目标。
  // Top 是 PDF 用户空间,从底边向上量
  PDF.CurrentPageNumber := 1;
  PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
    0, PDF.CurrentPage.Height - 50, 0, 0, 0);

  // 第 0 页上的目录条目;矩形用自上而下坐标
  PDF.CurrentPageNumber := 0;
  R := Rect(50, 175, 545, 209);
  PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');

  // YPos = -1 写出 [page /XYZ null null null]:换页、保持视图
  R := Rect(50, 225, 545, 259);
  PDF.CurrentPage.AddGoToLink(R, 2, -1, 'Methodology');

  R := Rect(50, 275, 545, 309);
  PDF.CurrentPage.AddURILink(R, 'https://www.example.com', 'Project website');
end;

把屏幕风格的 50 当 Top 传进去,链接会落在底边上方 50 点处,显示的是页脚而不是标题。命名目标住在目录的 /Dests 名称树里,每个链接都经名字解析,所以几十个链接指向同几章时它是正确之选。挪动一节,你只改 /Dests 里的一条;每个点名它的链接都跟着走。这些目标位置依赖的页面顺序见为什么页面树顺序要紧

注解:内容流之外的交互对象

注解坐在页面上却不属于它的内容流(ISO 32000-1 §12.5)。链接、便签、高亮、表单部件、文件附件图标和图章都住在自己页面的 /Annots 数组里。移走一条,注解就消失,内容流逐字节不变

每个注解共享一个小的核心(表 164):/Subtype 点名种类,/Rect 给出默认用户空间里的包围盒,/Contents 存的文本兼作辅助技术朗读的替代描述。链接注解(§12.5.6.5,表 173)有两种形态:

20 0 obj                                    % link to a destination
<< /Type /Annot /Subtype /Link
   /Rect [100 200 300 250]
   /Border [0 0 0]
   /Dest [5 0 R /XYZ null null null] >>
endobj
21 0 obj                                    % link that runs an action
<< /Type /Annot /Subtype /Link
   /Rect [50 50 200 100]
   /Border [0 0 0]
   /Contents (Project website)
   /A << /Type /Action /S /URI /URI (https://www.example.com) >> >>
endobj

/Border [0 0 0] 放在那有原因:默认边框是 [0 0 1],一个许多阅读器会画在每条链接周围的 1 点框。/Dest 和 /A 互斥,动作的 /S 挑行为:本文件内的 /GoTo(§12.6.4.2)、另一份 PDF 里的 /GoToR(§12.6.4.3)、网址的 /URI(§12.6.4.7)、打开外部应用或文件的 /Launch(§12.6.4.5)。分发的文档里别放 /Launch:阅读器通常封杀它或在它前面放安全提示,PDF/A 直接禁止它(ISO 19005-1 §6.6.1)。上面链接调用的 Description 参数填 /Contents,HotPDF 的 PDFUACompliance 打开时空描述会抛异常,因为 PDF/UA-1 要求每条链接都带这个条目

高亮和便签这类标记注解、/Square 这类图形注解多了一层曲折:它们的类型不定外观。没有外观流(/AP 条目,§12.5.5)时,每个阅读器画自己的版本。ISO 32000-2 对多数注解类型强制 /AP,Popup 和 Link 在例外之列。文件附件复用同一套机制:一个包在文件规格(§7.11)里的嵌入文件流,要么以页面上的 /FileAttachment 注解浮出,要么走目录 /Names 下的 /EmbeddedFiles 名称树

文件出门之前怎么检查这一层?

两个检查能抓住大半:在真实阅读器里把书签面板和一部分链接点一遍;再用程序把元数据读回来,因为点再多下也暴露不出与 XMP 相矛盾的 /Info。HotPDF 的已加载文档调用两头都覆盖。GetLoadedOutlineTitlesDeep 深度优先地走 /First 和 /Next 链——与阅读器同一条路——所以被过期链接截断的分支在它的结果里同样缺席。FindLoadedOutlineByTitle 解析书签的目标页,目标为命名目标或解析不到页面时报告 -1

// StripXmlTags 是你自己加的辅助函数;它不是库调用。
// GetLoadedXMPValue 返回元素的内层标记,所以 dc:creator
// 回来时包着 rdf:Seq 和 rdf:li 标签
function StripXmlTags(const S: AnsiString): AnsiString;
var
  I: Integer;
  InTag: Boolean;
begin
  Result := '';
  InTag := False;
  for I := 1 to Length(S) do
    if S[I] = '<' then InTag := True
    else if S[I] = '>' then InTag := False
    else if not InTag then Result := Result + S[I];
  Result := AnsiString(Trim(string(Result)));
end;

procedure CheckNavigationLayer(const FileName: string);
var
  PDF: THotPDF;
  Title, Author, Subject, Keywords, Creator, Producer, XmpAuthor: AnsiString;
  Titles: THPDFAnsiStringArray;
  I, PageIndex: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    if PDF.LoadFromFile(FileName) <= 0 then
      raise Exception.Create('No readable pages');

    if PDF.GetLoadedDocumentInfo(Title, Author, Subject, Keywords,
      Creator, Producer) then
    begin
      XmpAuthor := StripXmlTags(PDF.GetLoadedXMPValue('dc', 'creator'));
      if (XmpAuthor <> '') and (XmpAuthor <> Author) then
        Writeln('Author mismatch: /Info="', Author, '" XMP="', XmpAuthor, '"');
    end;

    Titles := PDF.GetLoadedOutlineTitlesDeep;
    Writeln(Length(Titles), ' bookmarks reachable');
    for I := 0 to High(Titles) do
      if PDF.FindLoadedOutlineByTitle(Titles[I], PageIndex, False, True)
        and (PageIndex < 0) then
        Writeln('No direct page target: ', Titles[I]);
  finally
    PDF.Free;
  end;
end;

书签数比你写的少,指向断掉的兄弟链。用命名目标的书签报 -1 是预期行为,FindLoadedOutlineByTitle 停在第一个匹配,所以重复标题报第一个的目标。GetLoadedXMPValue 是对包的纯文本搜索,比较逐字节进行,所以非 ASCII 名字遇到不匹配时,先看看两边各是什么样再下结论

这一层的一切都是引用簿记,而簿记正是值得交给库的部分。面向 Delphi 和 C++Builder 的 HotPDF Delphi Component 从同一组属性写出两套元数据存储、在追加条目时串好大纲链接、带着动作和描述挂上链接注解;它的已加载文档 API 把同样的结构读回来供检查