技術文章

PDF 中繼資料、大綱與註記詳解

同一份 PDF 可以回報兩個不同的作者。桌機檢視器的屬性對話框顯示一個名字,索引 XMP 的資產管理系統顯示另一個,而兩個程式都把檔案讀對了。PDF 把文件中繼資料存了兩份,書籤、連結與備註又放在從不接觸頁面內容的結構裡,所以任何一項都可能出錯,而每一頁照樣渲染得完美無缺

底層的物件模型(間接物件、交叉參考表、trailer)在 PDF 文件結構逐步解說一文有完整介紹。這裡聚焦在掛在文件 catalog 上的東西

catalog 裡的四個條目,四套獨立子系統

下面談到的一切,都是從文件 catalog——trailer 的 /Root 條目指向的那個字典(ISO 32000-1 §7.7.2)——出發可達的。把這些全部接起來的 catalog 長這樣:

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 文件 catalog 連結到頁面樹、大綱根、名稱字典與 XMP 中繼資料串流,四套皆可選的子系統
文件 catalog 接線四套獨立子系統,各自經由自己的條目可達。任何一套缺席,頁面照樣正常渲染

/Pages 是看得見的文件,也是四者中 catalog 唯一要求的。/Outlines 通往書籤樹,/Metadata 通往 XMP 串流,/Names 通往名稱字典——內嵌檔案放在 /EmbeddedFiles,具名目的地放在 /Dests。三個可選項一個都找不到的讀取器,照樣顯示每一頁,這正是檔案經過只懂頁面的工具時,這一層最先被丟掉的原因。頁面物件內部沒有任何東西指回大綱、/Dests 樹或 XMP 串流。只把頁面複製進新檔案,三樣全都留下

為什麼一份 PDF 帶著兩套中繼資料?

PDF 帶兩套中繼資料,是因為格式長出了第二套、卻沒有退役第一套。原始機制是文件資訊字典(ISO 32000-1 §14.3.3,Table 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——並從 catalog 的 /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,Table 152 與 153)。檔案裡任何地方都不存在書籤陣列;檢視器靠追蹤 catalog /Outlines 起點的參照,重建整個面板

PDF 示意圖:PDF 大綱樹中的 Parent、First、Last、Prev、Next 與 Count 鏈結,重建出檢視器的書籤面板
書籤面板是從一棵以 Parent、First、Last、Prev、Next 與 Count 串起的雙向鏈結大綱樹重建出來的。一條過期的鏈結就會無聲地藏起整個分支
8 0 obj                                    % 大綱根
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % 頂層:一章
<< /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                                   % 第一個子節點
<< /Title (Introduction)
   /Parent 9 0 R /Next 15 0 R
   /Dest [4 0 R /XYZ 72 720 0] >>
endobj
15 0 obj                                   % 第二個子節點,也是最後一個同層
<< /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,Table 151),而目的地不只是頁碼。它是一個陣列:先指名一個頁面物件,再給一個 fit 類型,告訴檢視器怎麼取景。主力是 [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] 把一個矩形放大到填滿視圖。這些在點擊當下按頁面幾何計算縮放;內建固定 zoom 的 /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 點的位置,顯示的是頁尾而不是標題。具名目的地住在 catalog 的 /Dests 名稱樹裡,每條連結都透過名稱解析,所以同一章要放幾十條連結時,它是正確的選擇。搬動一個章節,你只編輯 /Dests 裡的一條記錄;指名它的每條連結都跟著走。這些目的地依賴的頁面順序,見 為什麼頁面樹的順序很重要

註記:內容串流之外的互動物件

註記坐在頁面上,卻不是內容串流的一部分(ISO 32000-1 §12.5)。連結、便利貼、高亮、表單 widget、附件圖示與圖章,都活在所屬頁面的 /Annots 陣列裡。移除一條記錄,註記就消失,內容串流逐位元組保持原樣

每個註記共用一個小核心(Table 164):/Subtype 指名種類,/Rect 給出預設使用者空間裡的邊界框,/Contents 放的文字同時也是輔助技術朗讀的替代描述。連結註記(§12.5.6.5,Table 173)有兩種形態:

20 0 obj                                    % 指向目的地的連結
<< /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                                    % 執行動作的連結
<< /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],一個許多檢視器會畫在每條連結周圍的一點框。/Dest 與 /A 互斥,動作的 /S 決定行為:/GoTo 跳本檔(§12.6.4.2),/GoToR 跳另一份 PDF 的頁面(§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 註記現身,或經由 catalog /Names 底下的 /EmbeddedFiles 名稱樹

檔案出貨之前,怎麼檢查這一層?

兩個檢查能抓到大部分問題:在真實檢視器裡點過書籤面板與抽樣的連結,然後用程式把中繼資料讀回來——因為點再多次,也看不出 /Info 與 XMP 打架。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 字元時,先看看兩個值各自長什麼樣,再決定要不要相信一個 mismatch

這一層的一切都是參照簿記,而簿記正是值得交給函式庫的部分。供 Delphi 與 C++Builder 使用的 HotPDF Delphi Component 從同一組屬性寫出兩套中繼資料,在你加入條目時接好大綱鏈結,並連同動作與描述掛上連結註記;它的已載入文件 API 把同樣的結構讀回來供檢查