同一份 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
/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 元数据流
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 属性 |
|---|---|
/Title | dc:title (rdf:Alt) |
/Author | dc:creator (rdf:Seq) |
/Subject | dc:description (rdf:Alt) |
/Keywords | pdf:Keywords |
/Creator | xmp:CreatorTool |
/Producer | pdf:Producer |
/CreationDate, /ModDate | xmp: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 的引用走,重建出面板
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 把同样的结构读回来供检查