技術記事

DelphiでのPDF/A InfoとXMPメタデータ等価性チェック

PDFium Componentは、PDF/AのInfoとXMPメタデータの等価性をTPdf.InspectPdfAMetadataで検査し、TPdf.NormalizePdfAMetadataで修復します。ISO 19005-1(Cor.1による修正を含む)は、マップされた8つのInfoエントリ、TitleからModDateまでのそれぞれに対し、対応するXMPプロパティと同じ値を持つことを要求します。存在するだけでよいわけではありません。検査は正しいRDF構造を読み、名前空間をURIで照合し、日付を瞬間として比較します

この話のきっかけになる障害報告は、一見無害です。文書管理システムが増分保存のたびにInfoディクショナリへ新しい/ModDateを押し込み、XMPパケットには触れない。半年後、アーカイブ監査が数千のファイルを不適合としてフラグします。両方の日付は存在します。最初の編集で食い違っただけで、存在チェックはそれに気づかなかったのです。Info専用API経由で行われたタイトル編集も、ツールによって2つのdc:creator項目に分割されたFinance; ControllingのようなAuthor文字列も、同じように引っかかります

両側に存在するメタデータをPDF/Aが拒む理由

PDF/Aが拒むのは、ISO 19005-1 §6.7.3が値のルールであって存在のルールではないからです。Table 1は8つのInfoキーをXMPプロパティへマップし、Infoキーが存在した時点で、マップ先のXMPプロパティは等価な値を保持しなければなりません。PDFium ComponentによるPDF/Aプレフライト検証で説明したバイトレベルスキャナは、xmp:CreateDateとxmp:ModifyDateが存在することしか確認しません(pvaiMissingXmpDates)。v3.72.0から、TPdf.ValidatePdfAはさらに完全な値比較を実行し、XMPパケットが存在するのにInfoと食い違う場合(パースできないパケットは食い違いと数える)、pvaiInfoXmpValueMismatchを問題セットへ追加します。パケットが欠けている場合はpvaiMissingXmpMetadataのまま報告されるため、2つの問題が同じ欠陥を二重に数えることはありません

各マップ先XMPプロパティが必要とするRDF構造

8つのマッピングにはそれぞれ固定のXMP型があり、正しい値でもコンテナを間違えれば失敗します。FPdfPdfa.pasのComparePdfAInfoAndXmpはプロパティを名前空間URIで引き、http://purl.org/dc/elements/1.1/を珍しい接頭辞に束縛しているパケットも、dcを使うパケットとまったく同じように読みます。要求される構造は以下の通りです

  • Title → dc:title、Subject → dc:description:rdf:Altの言語代替。比較対象はx-default項目だけ(言語タグは大小を区別せず照合)。x-defaultを持たないAltは欠落と数える
  • Author → dc:creator:Info文字列全体を保持するテキスト項目をちょうど1つ持つrdf:Seq。セミコロン区切りの著者リストも1つのエントリのままにする
  • Keywords → pdf:Keywords、Producer → pdf:Producer(名前空間http://ns.adobe.com/pdf/1.3/):単純なテキストプロパティ
  • Creator → xmp:CreatorTool、CreationDate → xmp:CreateDate、ModDate → xmp:ModifyDate(名前空間http://ns.adobe.com/xap/1.0/):単純なテキストプロパティ
DelphiでComparePdfAInfoAndXmpがPDF/Aメタデータ等価性のためにチェックする8組のマップペア:TitleとSubjectはx-default項目を持つrdf:Altを、Authorは1項目のrdf:Seqを必要とし、Keywords、Producer、Creator、2つの日付は単純テキストで、どれも接頭辞ではなくXMP名前空間URIで引きます
Infoキーが存在した時点で、ISO 19005-1はマップ先のXMPプロパティに要求されるRDF構造での等価な値を課すため、コンテナを間違えた値は正しくても失敗します
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report 2026</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Finance; Controlling</rdf:li></rdf:Seq></dc:creator>
<pdf:Producer>PDFium Component</pdf:Producer>

テキスト値はUnicodeコードポイント列として正確に比較されます。トリミングも、大文字小文字の折りたたみも、正規化もありません。末尾の空白、あるいは片側が合成済みのéでもう一方がeと結合アクセントの分解形なら、それは本物のミスマッチです。Info側は常に、FPDF_GetMetaTextによるPDFium自身のPDFDocEncoding・UTF-16テキストのデコードから来ます。ライブラリが文字列デコードを再実装して微妙に間違えるのを避けるためです。XMP側の清潔さはそれを生んだバイト列次第であり、Free PascalでXMPメタデータを壊すコードページの罠がここでも効いてくるのはそのためです

PDF日付とXMP日付が等しいと言える条件

PDF日付とXMP日付が等しいのは、秒単位で同じ瞬間を、両側が同じタイムゾーン知識を持って記述しているときです。両パーサーは正当な省略精度を受け入れるため、D:2026も2026も2026年1月1日00:00:00を意味します。両方の値がゾーンを持つなら、比較前にUTCへ変換されます。D:20260827093659+08'00'は2026-08-27T01:36:59Zと等しくなります。どちらもゾーンを持たないなら、ローカルの構成要素が書かれたまま比較されます。片側だけがゾーンを持つなら結果はpamsValueMismatchです。オフセットをでっち上げるのは推測にすぎません。XMPの.250のような非ゼロの小数秒もミスマッチを強制します。PDF日付にはそれを表現する手段がなく、黙って丸めれば本物の食い違いを隠してしまうからです。.000は受け入れられます。パースできない値はpamsInvalidInfoDateかpamsInvalidXmpDateとして別々に報告されます

PDFium ComponentがDelphiでPDFのInfo日付とXMP日付の等価性を判定する方法:ゾーンが両方あればUTCへ変換して瞬間を比較し、ゾーンなしが両方なら書かれたまま比較し、片側だけのゾーンはpamsValueMismatch、非ゼロの小数秒は表現できず、パースできない値は別々に報告します
等価とは秒単位の同じ瞬間に、両側が同じタイムゾーン知識を持つことです。オフセットのでっち上げや小数秒の丸めは、本物の食い違いを隠してしまいます

存在には専用のルールがあります。TPdfAMetadataValues.Presentはアクティブtrailerの/Infoディクショナリをたどって満たされるセットであり、「キーが無い」と「キーはあるが空文字列」を区別します。無いキーはpamsNotRequiredとなり、XMPには何も要求しません。/Title ()は存在するので、XMPパケットも空のx-defaultタイトルを運ばなければなりません

保存前にInfoとXMPメタデータを検査する方法

TPdf.InspectPdfAMetadataはフィールドごとに1つのTPdfAMetadataComparisonを持つTPdfAMetadataReportを返します。それぞれがInfo値、XMP値、TPdfAMetadataStateを保持するため、失敗を1つの検証フラグを逆算することなく説明できます。MismatchFieldsが失敗セットを要約し、HasXmpPacketがパケットが見つかったかを教え、パケットが存在するのに読めないときはXmpParseErrorがパーサーのメッセージを運びます

uses
  System.SysUtils, PDFium, FPdfPdfa;

const
  FieldNames: array[TPdfAMetadataField] of string = (
    'Title', 'Author', 'Subject', 'Keywords',
    'Creator', 'Producer', 'CreationDate', 'ModDate');
  StateNames: array[TPdfAMetadataState] of string = (
    'not required', 'equivalent', 'XMP missing', 'XMP type mismatch',
    'value mismatch', 'invalid Info date', 'invalid XMP date');

procedure ReportMetadata(Pdf: TPdf);
var
  Report: TPdfAMetadataReport;
  Item: TPdfAMetadataComparison;
begin
  Report := Pdf.InspectPdfAMetadata;
  if Report.XmpParseError <> '' then
    Writeln('XMP packet unreadable: ', Report.XmpParseError)
  else if not Report.HasXmpPacket then
    Writeln('No XMP packet at all');
  for Item in Report.Comparisons do
    if not Item.IsEquivalent then
      Writeln(Format('%-12s %-18s Info="%s" XMP="%s"',
        [FieldNames[Item.Field], StateNames[Item.State],
         Item.InfoValue, Item.XmpValue]));
end;

NormalizePdfAMetadataが変えるものと、拒むもの

TPdf.NormalizePdfAMetadataはInfoディクショナリを正とし、MismatchFieldsに入ったフィールドのXMPプロパティだけを書き直します。パケット内のそれ以外は生き残ります。TitleとSubjectはx-default項目へ書き込まれ、他の言語代替はそのまま保たれ、Authorは1項目のrdf:Seqになります。未知の名前空間と無関係なプロパティは保持され、存在しないInfoキーに対応するXMPプロパティは触られません。ゾーン付きのInfo日付はZ接尾辞付きの正規UTC XMP日付として書かれ、ゾーンなしのものはローカル構成要素を保ちます。ファイルオーバーロードは一時ファイルとアトミック置換を通して保存し、XMP更新自体は増分更新として追加されます

PDFium ComponentでDelphiのPDF/Aメタデータを修復するときNormalizePdfAMetadataが書き直すもの:Infoが正であり、MismatchFieldsのエントリだけがx-defaultのAltテキスト、1項目のSeq、正規UTC日付として書き戻され、未知の名前空間、無関係なプロパティ、不在キーのプロパティはそのまま生き残ります
修復は、XMPパケットの欠落、不正なInfo日付、署名付き文書を拒みます。完全なPDF/Aメタデータセットの構築は、ピンポイントの等価性修正ではなくSaveAsPdfAの仕事だからです

拒否は意図的です。XMPパケットがない場合、メソッドはEPdfErrorを投げます。完全なPDF/A識別とメタデータセットの構築はSaveAsPdfAの仕事であり、PDFium ComponentでPDF/Aアーカイブファイルを作成するで扱っています。不正なInfo日付は、それらしく見える間違った値を書く代わりにEPdfXmpErrorを投げ、何も保存しません。署名付き文書は、呼び出し側がAllowSignedDocument = Trueを渡さない限り拒否されます。等価性はISO 19005-1のルールの1つにすぎないため、正規化されたファイルが自動的に適合するわけでもありません

uses
  System.SysUtils, PDFium, FPdfPdfa, FPdfXmp;

procedure NormalizeArchive(const Source, Target: string);
var
  Pdf: TPdf;
  Report: TPdfAMetadataReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Report := Pdf.InspectPdfAMetadata;
    if Report.IsEquivalent then
      Exit;                      // すでに整合している。ファイルには触れない
    if not Report.HasXmpPacket then
      raise Exception.Create('No XMP packet: convert with SaveAsPdfA instead');
    try
      if not Pdf.NormalizePdfAMetadata(Target) then
        raise Exception.Create('Normalized save failed');
    except
      on E: EPdfXmpError do      // 不正なInfo日付または読めないパケット
        raise Exception.CreateFmt('Cannot normalize %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

自分のXMPパケットで比較を実行する

ComparePdfAInfoAndXmpとSynchronizePdfAInfoToXmpはFPdfPdfaの素の関数であり、文書をロードせずにTPdfXmpPacketに対して動きます。単体テストや、テンプレートからXMPを組み立てるパイプラインに向いています。1つだけ罠があります。Presentです。Default(TPdfAMetadataValues)で初期化されたレコードは空セットを持ち、すべてのフィールドがpamsNotRequiredを報告し、どんな値を詰め込んでいようと比較は空虚なまま通ります

uses
  System.SysUtils, System.IOUtils, FPdfPdfa, FPdfXmp;

procedure AlignTemplate(const TemplateFile: string);
var
  Info: TPdfAMetadataValues;
  Packet: TPdfXmpPacket;
  Changed: TPdfAMetadataFields;
begin
  Info := Default(TPdfAMetadataValues);
  Info.Title := 'Quarterly Report 2026';
  Info.Author := 'Finance; Controlling';
  Info.ModDate := 'D:20260827093659+08''00''';
  // Presentがどのフィールドを必須にするか決める。値だけでは無視される
  Info.Present := [pamfTitle, pamfAuthor, pamfModDate];

  Packet := TPdfXmpPacket.Parse(TFile.ReadAllText(TemplateFile, TEncoding.UTF8));
  try
    Changed := SynchronizePdfAInfoToXmp(Info, Packet);
    // xmp:ModifyDateは2026-08-27T01:36:59Zになり、dc:creatorは1項目のrdf:Seqになる
    if Changed <> [] then
      TFile.WriteAllBytes(TemplateFile, Packet.ToUtf8);
  finally
    Packet.Free;
  end;
end;

他システムが編集し続ける文書をアーカイブするパイプラインなら、夜間のInspectPdfAMetadataの一括検査と、ずれたファイルへのNormalizePdfAMetadataを組み合わせ、長期保管へ何かが送られる前にValidatePdfAをゲートとして置いてください。型付きレポート、修復経路、その他のPDF/Aツール一式は、DelphiとC++Builder向けPDFium Componentに搭載されています