技術記事

PDF のメタデータ、アウトライン、注釈の解説

同じ PDF が、2つの異なる著者を報告することがあります。デスクトップビューアのプロパティダイアログが示す名前と、XMP をインデックスする資産管理システムが示す名前が違い、どちらのプログラムもファイルを正しく読んでいます。PDF は文書メタデータを2か所に格納し、ブックマーク、リンク、注釈はページコンテンツに決して触れない構造に保持します。だからどれも、すべてのページが完璧に描画されているあいだに、間違っていられるのです

その下にあるオブジェクトモデル(間接オブジェクト、クロスリファレンステーブル、トレーラー)は、PDF 文書構造のウォークスルーで扱っています。ここでの焦点は、文書カタログにぶら下がるものに絞ります

カタログの4つのエントリ、4つの独立したサブシステム

以下で論じるすべては、文書カタログ、つまりトレーラーの /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 メタデータストリームへの 4 つの任意サブシステムとしてのリンク
ドキュメントカタログは 4 つの独立したサブシステムを配線し、それぞれが自分の入り口から到達できます。どれかが欠けても、ページは普通に描画できます

/Pages は見える文書であり、カタログが要求する4つのうち唯一のものです。/Outlines はブックマークツリーへ、/Metadata は XMP ストリームへ、/Names は名前辞書へ続きます。名前辞書は /EmbeddedFiles の下に埋め込みファイルを、/Dests の下に名前付き宛先を保持します。オプションの3つがどれも見つからないリーダーでも、すべてのページは表示されます。だからこの層は、ページしか理解しないツールを通ったファイルで、最初に失われる層なのです。ページオブジェクトの中の何ものも、アウトラインも /Dests ツリーも XMP ストリームも指して戻りません。ページだけを新しいファイルへコピーすると、3つすべてが置き去りになります

PDF が2つのメタデータストアを運ぶのはなぜか

PDF が2つのメタデータストアを運ぶのは、フォーマットが1つ目を引退させないまま2つ目を育てたからです。元の機構は文書情報辞書(ISO 32000-1 §14.3.3、Table 317)で、トレーラーの /Info が参照します。/Title、/Author、/Subject、/Keywords、/Creator、/Producer のフラットなテキストエントリに、/CreationDate、/ModDate、/Trapped を加えたものです。ISO 32000-2 は、2つの日付エントリを除く辞書全体を非推奨にし、書き手を XMP メタデータストリームへ向けさせます

PDF Info ディクショナリと XMP メタデータストリームの PDF 比較図。重複フィールドを持つ 2 つの格納域が同期を保つべきで、XMP が真実の情報源
Info 辞書と XMP ストリームは、同じ問いに並行して答えます。1 組の値から両方を再生成して同期を保ってください

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

このブロックの中の3つの細部が、パケットが実際のツールを生き延びるかを決めます。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

2つがずれるのは、手作業の編集とマージです。マージは、ある入力から /Info を、別の入力から XMP パケットを引き継げます。より安全な規則は、XMP を真実の情報源として扱い、古い消費者のために /Info を保ち、どちらかが変わったときは1組の値から両方を再生成することです

1組のプロパティから両方のストアを書く

HotPDF は両方のストアを、同じ文書プロパティから組み立てます。Title、Author、Subject、Keywords は /Info に入り、EnableXMPMetadata が True のとき、EndDoc はさらに同じ値から XMP パケットを書き、dc:title を rdf:Alt として、dc:creator を rdf:Seq として、カタログの /Metadata からリンクします。EnableXMPMetadata の既定は False ですが、こちらの手を離れるものすべてでオンにする価値があります。/Info だけのファイルは、XMP だけを読むすべてのツールには見えないのです。追加のスキーマが要るときは、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;
    //  1 組の値が /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 は、冒頭の段落の「著者が2人」のファイルをそっくりそのまま作ります

ブックマークパネルの背後にあるアウトラインツリー

ブックマークパネルは、辞書の二重リンク木、文書アウトライン(ISO 32000-1 §12.3.3、Table 152 と 153)から組み立て直されます。ブックマークの配列はファイルのどこにも存在せず、ビューアはカタログの /Outlines から参照をたどってパネルを再構築します

PDF アウトラインツリーの PDF 図。Parent、First、Last、Prev、Next、Count リンクがビューアーブックマークパネルを再構築
ブックマークパネルは、Parent、First、Last、Prev、Next、Count で貫かれた双方向リンクの目次ツリーから組み立て直されます。1 つの古いリンクが、エラーなしで枝全体を隠します
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 で両端を名指し、そのあいだのものは、兄弟のウォークの中の1ステップとしてしか存在しません。リンクを1本切ると、ビューアはまだ届くものを表示します。古くなった /Next は章を途中で切り、チェーンの終わりと食い違う /Last は、ビューアごとに違う木を描かせます

/Count が人を驚かせるのは、パネルの状態を格納するからです。ルートでは、すべてのレベルで見えている項目の数です。項目では、正の値は開いていることを意味し、見えている子孫の数を与えます。負の値は閉じていることを意味し、絶対値は展開したときに現れる子孫の数です。サンプルのルートは3を持ちます。1つの章と、その開いた2人の子です。すべての /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] です。3つのオペランドのどれも 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 の1エントリを編集します。それを名指すすべてのリンクがついてきます。宛先が依存するページ順については、ページツリーの順序がなぜ大事かで扱っています

注釈:コンテンツストリームの外のインタラクティブなオブジェクト

注釈は、コンテンツストリームの一部ではないまま、ページの上に座ります(ISO 32000-1 §12.5)。リンク、付箋、ハイライト、フォームウィジェット、添付ファイルアイコン、スタンプはすべて、自分のページの /Annots 配列に住んでいます。エントリを取り除くと注釈は消え、コンテンツストリームはバイト単位で同じままです

すべての注釈は小さな中核を共有します(Table 164)。/Subtype が種類を名指し、/Rect が既定ユーザー空間でのバウンディングボックスを与え、/Contents が、支援技術が読み上げる代替説明を兼ねるテキストを保持します。リンク注釈(§12.5.6.5、Table 173)には2つの形があります

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 名前ツリーを通して現れます

ファイルを出荷する前に、この層をどう検査するか

2つの検査がほとんどを捉えます。実際のビューアでブックマークパネルとリンクのサンプルをクリックして回ること、そしてメタデータをプログラムで読み戻すことです。クリックをいくら重ねても、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 は、1組のプロパティから両方のメタデータストアを書き、項目を追加するとアウトラインのリンクを通し、アクションと説明付きでリンク注釈を取り付けます。ロード済み文書の API は、検査のために同じ構造を読み戻します