同じ 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
/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 メタデータストリームへ向けさせます
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 プロパティ |
|---|---|
/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 |
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 から参照をたどってパネルを再構築します
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 は、検査のために同じ構造を読み戻します