技術記事

Delphi で読み込んだ PDF のメタデータを再生成せずに編集する

契約 PDF が 1 万件あり、生成元は十数種類。法務からは、それぞれに適切な Author、修正した Producer 文字列、そして起動時にブックマークペインを開く閲覧モードが必要だと言われています

正しいやり方は、読み込んだ文書をインプレースで変更するオブジェクトグラフとして扱うことです。Info dictionary、/Metadata ストリーム、Catalog に入り込み、必要な項目だけを変更して結果を書き戻します。Delphi と C++Builder 向けのネイティブ VCL PDF コンポーネントである HotPDF は、読み込み済み文書を書き戻す API を通じてまさにその操作面を提供します。この記事では、その正しい使い方と、ほとんど誰もがやってしまう 1 つの失敗、つまり Info dictionary を編集して、同じメタデータの別コピーが XMP にもあることを忘れる点を扱います

同じメタデータは 2 か所にあり、しかも食い違う

PDF は文書情報を 2 つの並行した場所に保持しており、これは「Title を変えたのに Acrobat では古いまま」といった問い合わせの大半の原因です。最初の場所は文書情報辞書、つまり /Info オブジェクトで /Title/Author/Subject/Keywords/Creator、および /Producer キーを持つ標準の <code>/Info</code> オブジェクトです。これらは ISO 32000-1 §14.3.3 で定義されています。もう一方は XMP パケットで、Catalog の下の /Metadata にぶら下がるストリームとして保存された XML 文書であり、§14.3.2 で定義され、Adobe XMP データモデルを基にしています

どちらにもタイトルを入れられます。仕様は両者の一致を強制しません。現代のビューアや PDF/A 検証ツールの大半は、XMP パケットがあるときはそれを優先し、ないときだけ Info dictionary にフォールバックします。したがって /Info を更新するだけなら、これは多くの「PDF メタデータを設定する」コードがやっていることですが、XMP を信頼するリーダーは古い値を表示し続け、PDF/A チェッカーは不一致を警告します。XMP パケットをすでに持つファイルで正しく行うべき処理は二重書き込みです。Info エントリを 更新し XMP を再生成し、両者を一致させます。HotPDF は両方の機能を提供しますが、一緒に使いこなすかどうかはあなた次第です

Info dictionary の編集

Info 側のヘルパーは薄く、予測しやすい作りです。SetLoadedTitleSetLoadedAuthorSetLoadedSubjectSetLoadedKeywordsSetLoadedCreator、および SetLoadedProducer は、それぞれ単一の AnsiString を受け取り、読み込み済みの Info dictionary に対応するキーを書き込みます。キーが存在すれば値を置き換え、なければ追加します。キーを完全に消したい場合、たとえば内部ツール名を記してしまう漏れのある /Creator を消したいなら、RemoveLoadedInfoKey を素のキー名だけで呼び出します。これらは XMP には触れません。処理対象は、/Info オブジェクトだけです。LoadFromFile がファイルを解析したときに見つけたものです

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // drop the originating tool name
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

1 つだけきちんとしておきたい点があります。これらは AnsiString。ASCII のタイトルなら問題ありませんが、PDF の文字列で非ラテン文字が必要な場合は、仕様どおり UTF-16BE with a byte-order mark か PDFDocEncoding でエンコードしてから渡す必要があります。ライブラリは、渡されたバイト列を文字列オブジェクトにそのまま書き込み、エンコーディングを推測しません。英語だけのタイトルなら気にしなくて構いません。アクセント付き文字や CJK 文字を含む場合は、意図してエンコードし、実際のビューアで確認してください

XMP パケットを書き直す

SetLoadedXMPMetadata は二重書き込みのもう半分です。完全な XMP パケットを AnsiString として渡すと、動作は 2 通りです。Catalog がすでに /Metadata ストリームを参照していれば、その内容を同じオブジェクト番号のままインプレースで置き換えます。メタデータストリームがなければ、新しく作成して /Type /Metadata かつ /Subtype /XML に設定し、オブジェクト番号を割り当てて Catalog からリンクします。どちらの経路でも、ビューアが読み取れる有効なメタデータオブジェクトが最終的にできます

XML を渡すので、スキーマを自分で制御できます。dc:titledc:creatorxmp:CreatorToolなどです。これは力であると同時に責任でもあります。ライブラリはパケットを解析も検証もしませんし、ストリームフィルターをかけずに非圧縮のままバイト列を書き込みます。壊れたパケットでも呼び出しは通り、あとでメタデータ破損の警告として表面化します。XML は慎重に構成し、Info dictionary に書いた値と完全に一致させて、2 つの見え方が決して食い違わないようにしてください

const
  XMP_TEMPLATE =
    '<?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/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // After setting the Info dictionary, mirror the same values into XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

その順序、つまり Info を先に、XMP を後に、そのあと保存、を身につけるべきパターンです。2 つの呼び出しは独立しており、一貫性が保たれるのは同じ文字列を渡したからにすぎません。XMP パケットを持つファイルで XMP 呼び出しを省くと、この節全体が防ごうとしている、黙ったまま古い値が残る不具合に逆戻りします

Diagram showing a PDF Info dictionary and an XMP metadata stream both holding title and author, edited in place alongside the bookmark outline tree
メタデータは 2 か所、つまり Info dictionary と XMP stream に加え、Catalog レベルの読み取りヒントとアウトラインツリーにもあります。インプレース編集なら、文書を再構築せずにそれぞれへ触れます。

ビューアがファイルを開いたときの表示を制御する

文書を開いた瞬間に読者が何を見るかは 3 つの Catalog エントリで決まり、いずれも読み込み済みグラフに対する 1 行の編集です。 SetLoadedPageMode/PageMode を name オブジェクトとして書き込みます。'UseOutlines' ブックマークパネルを開くには、'UseThumbs' サムネイル列には、'FullScreen' プレゼンテーションモードには、または 'UseAttachments' 添付ファイルペインを表示するには使います(ISO 32000-1 §7.7.3.1、表 28)。 SetLoadedPageLayout/PageLayout も同様です。'SinglePage''OneColumn''TwoColumnLeft'、その他の値があります。どちらも先頭のスラッシュなしで名前を受け取り、出力時にライブラリが補います

SetLoadedLanguage は Catalog の /Lang エントリを書き込み、文書全体の自然言語タグとして扱います。'en-US''de-DE'、BCP 47 タグです。紛らわしい型の違いに注意してください。/PageMode/PageLayout は PDF name オブジェクトです。一方、/Langstring です。HotPDF は内部でこれを正しく処理しますが、出力を確認すると /PageMode /UseOutlines/Lang (en-US) が並んで表示されるのが分かります。これで理由も分かるはずです。/Lang エントリは見た目以上に重要です。音声読み上げ支援が発音を選ぶ際に読むのがこれであり、PDF/UA のアクセシビリティ適合における必須要件でもあります

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, a name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, a string
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

ブックマーク名をツリーを崩さずに変更する

ブックマークのタイトル修正は日常的な後片付けです。見出しの টাইपोや、アウトライン作成後に章番号が変わった場合などです。SetLoadedOutlineTitle は 0 始まりのインデックスで 最上位 のアウトライン項目を指定し、新しいタイトルを受け取ると、Catalog → /Outlines/First/Next までたどって、その位置のエントリの /Title 文字列を置き換えます。変更するのはタイトルだけで、遷移先、開閉状態、子の構造はそのままです

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

名前の変更が安全なのは、構造カウンタに触れないからです。削除は厄介なケースですが、名前変更だけが目的でも理解しておく価値があります。何を やってはいけないか というと、各アウトラインノードは /Count を持っており、ISO 32000-1 §12.3.3 によれば、その数は直下の子の数ではありません。これは visible descendants: 正の /Count の値なら N 個の子孫が現在展開されていることを意味し、負の値ならそのノードには子孫があるが折りたたまれています。最上位項目が削除されたとき、/Outlines の root count は 1 つ減らすだけでは済まず、生き残った各最上位ノードについて「ノード自身 1 つと、その正の /Count," を合計して再計算する必要があります。折りたたまれた(負の Count)ノードの子孫はその計算から除外します。ここを間違えると、読者に見えるブックマーク総数がずれ、1 回の削除で 1 を超えて変動します。名前変更ならこのすべてを避けられるので、辞書を手でいじるより対象のヘルパーを使うほうが理にかなっています

保存がインプレースのまま維持される仕組み

上の編集はすべてメモリ上のオブジェクトを変更するだけで、SaveLoadedDocument が実行されるまでディスクには何も書かれません。この方法が安価なのは、保存時に文書を再生成しないからです。既存のオブジェクト番号と、ロード時に HotPDF が解析した構造を保持したまま、変更済みと新規割り当ての少数のオブジェクトを含む同じグラフを書き戻します。これによりメタデータ更新でファイル全体を再書き込みせずに済みますし、オブジェクトストリームとインクリメンタル更新 を動かしているインプレース更新の仕組みと同じです。Word や他のオフィススイートから出てきたソースファイルは、編集前に知っておくべき独自のオブジェクト配置の癖があります。Office PDF におけるハイブリッド参照クロスリファレンスストリーム が、そうしたファイルの構造とラウンドトリップ後に残るものを説明しています

ここには 2 つの境界があります。まず、これはインプレース編集モデルであって、墨消しや無害化のツールではありません。Info キーを削除してもそのキーは消えますが、同じファイルの以前のインクリメンタル更新世代に残っている古い値までは消去しません。機密メタデータを本当に削除したいなら、別のもっと重い処理です。次に、XMP の書き込みは文字どおりです。ライブラリは XML を信頼し、検証しません。したがって PDF/A や厳格な検証ツール向けなら、既知の良いテンプレートからパケットを生成し、出力を確認してください。これらの範囲で使うなら、インプレースのメタデータ編集はちょうどよい道具です。間違っている数バイトだけを直し、すでに正しかったファイルの 99% は、元の作成者が書いたとおりにそのまま残します

ここで示した読み込み済み文書への書き込み API は、標準の HotPDF Component for Delphi と C++Builder に、メタデータ、アウトライン、Catalog 編集メソッド一式とともに含まれています