技術記事

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

/ModDate (D:20260827093659+08'00')を保持するInfoディクショナリと、xmp:ModifyDateとして2026-08-27T01:36:59Zを保持するXMPパケットは、同じ秒を記述しています。PDF/Aバリデーターには、このペアを受け入れる十分な理由があります。ところが文書のSubject文字列をdc:subjectへコピーすると、テキストが1文字残らず一致していても、パケットは失敗します。ISO 19005-1 §6.7.3が求めているのは、特定のXMPプロパティの中に、特定のRDF構造で保持された等価な値なのです

この話の口火を切る障害報告はたいてい無害に見えます。文書管理システムが増分保存のたびにInfoディクショナリへ新しい/ModDateを打刻し、XMPパケットは放置する。数ヶ月後、アーカイブ監査が何千ものファイルにフラグを立てます。日付は両方ともそこにあります。最初の編集で一致しなくなり、プロパティの存在だけを見るチェックはそれを一度も見ていません。Info専用APIを通したタイトル編集も同じ形で失敗しますし、Finance; ControllingのようなAuthor文字列を、あるツールが2つのdc:creator項目に分割してしまう場合も同じです

ISO 19005-1がInfoとXMPに求めるもの

ISO 19005-1のTable 1は8つのInfoキーをXMPプロパティと対にし、§6.7.3は、Infoキーが存在する場合、マップ先のXMPプロパティが等価な値を運ばなければならないと言います。存在しないキーは何も要求しません。PDF/A-2とPDF/A-3は同じ8キーについて同じ等価性要件を維持します。PDF/A-4は逆方向に進み、Infoディクショナリ自体を制限します。カタログの/PieceInfoと並んでいるときに限り現れ、ModDate以外を保持してはならない。この状態はTPdf.ValidatePdfAがpvaiPdfA4InfoWithoutPieceInfoとpvaiPdfA4InfoInvalidで報告します

PDFium Componentでは、比較は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構造での等価な値を課すため、コンテナを間違えた値は正しくても失敗します

Subjectの行が、手作りテンプレートが最も頻繁に壊れる場所です。Dublin Coreのdc:subjectは順序なしキーワードのbagであり、この名前が人々を誘惑してInfoのSubjectをそこへ放り込ませます。比較はdc:descriptionを探し、何も見つけず、SubjectをpamsXmpMissingとして報告します。「正しい」テキストは1つ隣のプロパティに座ったままです。正しい構造の3行は次のようになります:

<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>

正しい文字列がそれでも比較に失敗するのはなぜか

正しい文字列でも、間違ったコンテナに座っていたり、1コードポイント違っていれば失敗します。テキスト値は正確なUnicodeコードポイント列として比較され、トリミングも大文字小文字の折りたたみも正規化もありません。末尾のスペースはミスマッチです。片側が事前合成のé、もう片側がe+結合アクサンの分解形もミスマッチです。画面ではたいてい同じように表示されるのに、です

構造エラーには専用の状態があります。プレーンテキストとして書かれたdc:title、rdf:Bagとして宣言されたdc:creator、2項目のdc:creator Seqは、いずれもpamsValueMismatchではなくpamsXmpTypeMismatchを作ります。したがってレポートは「ツールが間違ったRDF構造を選んだ」と「ツールが違うテキストを書いた」を区別できます。2つの問題はたいていパイプラインの別の場所から来るため、間違った方を直すと午後が1つ無駄になります

Info側は常に、FPDF_GetMetaTextによるPDFium自身のPDFDocEncodingとUTF-16BE文字列のデコードから来ます。TPdf.GetMetadataが使うのと同じ経路なので、バイトオーダーマーク付きやエスケープされた8進列の文字列は、エンジンが読む通りのままです。XMP側は、それを作ったバイト列と同じくらいしかきれいではありません。間違ったコードページで組み立てられたパケットは、どんな比較も赦さない文字化けを運べます。だからこそFree PascalでXMPメタデータを壊すコードページの罠が同じチェックリストに載っているのです

PDF日付とXMP日付はいつ等しいか

PDF日付とXMP日付が等しいのは、両側が同じタイムゾーン知識を持って、秒単位で同じ瞬間を記述しているときです。両パーサーは正当な省略精度を受け入れるため、D:2026も2026も2026年1月1日の深夜0時を意味します。両値がゾーンを持つときは、それぞれがまず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、非ゼロの小数秒は表現できず、パースできない値は別々に報告します
等価とは秒単位の同じ瞬間に、両側が同じタイムゾーン知識を持つことです。オフセットのでっち上げや小数秒の丸めは、本物の食い違いを隠してしまいます

2つの日付文法は互換ではありません。パーサーは各側にそれぞれの文法を課します。PDF側では、オフセット分数を囲むアポストロフィは省略可能で、Zが受け入れられます。XMP側ではオフセットにコロンが必要です。2026-08-27T09:36:59+08:00はパースされますが、+0800はpamsInvalidXmpDateです。XMPの時刻はTが現れたら少なくとも時と分を必要とし、14時間を超えるオフセットは両側で拒否されます。日付をパースする前に前後の空白は取り除かれます。テキストフィールドが受けられる配慮です

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

ファイルに触れる前にフィールドごとのレポートを読む

TPdf.InspectPdfAMetadataは、フィールドごとに1つのTPdfAMetadataComparisonを持つTPdfAMetadataReportを返します。各エントリはInfo値、XMP値、TPdfAMetadataStateを保持するため、失敗をフィールドごとに説明できます。MismatchFieldsは失敗セットを集め、HasXmpPacketはパケットが見つかったかを告げ、XmpParseErrorは、パケットが存在するのに読めないときにパーサーのメッセージを運びます。最後のケースでは、すべてのpresentフィールドがpamsXmpMissingを報告します。パケットから何も読めなかったからです

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;

TPdf.ValidatePdfAは同じ比較を課題セットへ折り込んでいます。PDFium ComponentでのPDF/Aプリフライト検証で説明したバイトレベルのスキャンは、パケットが両方の日付プロパティを欠くとpvaiMissingXmpDatesを、InfoがCreationDateやModDateを保持するのにパケットに対応する日付プロパティがないとpvaiInfoXmpDateMismatchを報告します。その上でValidatePdfAは完全な値比較を実行し、パケットが存在してInfoと食い違うなら、読めないパケットも含め、pvaiInfoXmpValueMismatchを足します。パケットをまったく持たない文書はpvaiMissingXmpMetadataだけとして報告されるため、1つの欠陥が2回現れることはありません。ストリームレベルのValidatePdfACompliance関数は、バイトレベルのスキャンだけを行います

function MetadataGateOk(Pdf: TPdf): Boolean;
var
  Res: TPdfAValidationResult;
begin
  Res := Pdf.ValidatePdfA;
  if pvaiMissingXmpMetadata in Res.Issues then
    Writeln('No usable XMP packet: rebuild the file with SaveAsPdfA');
  if pvaiInfoXmpDateMismatch in Res.Issues then
    Writeln('Info has a date property the XMP packet lacks entirely');
  if pvaiInfoXmpValueMismatch in Res.Issues then
    Writeln('Mapped values disagree: run InspectPdfAMetadata for details');
  Result := Res.IsCompliant;
end;

NormalizePdfAMetadataが書き直すものと、止まる場所

TPdf.NormalizePdfAMetadataはInfoディクショナリを正として扱い、フィールドがMismatchFieldsに落ちたXMPプロパティだけを書き直します。TitleとSubjectはx-default項目に入り、他の言語代替は無傷のままです。Authorは1項目のrdf:Seqになります。ゾーン付きのInfo日付はZ接尾辞付きの正準UTC XMP日付として書かれ、ゾーンなしのものはローカルの構成要素を保ちます。未知の名前空間、無関係なプロパティ、不在InfoキーへのXMPプロパティは放置され、既存の接頭辞束縛はプロパティの書き戻しで再利用されます

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

Infoを正にするには、実ファイルで現れるコストがあります。「Finance」と「Controlling」を2人として列挙するdc:creator Seqは、Finance; Controllingと読む1項目に置き換えられ、間違ったコンテナに保存されたdc:titleはx-defaultだけを保持する新しいAltとして組み立て直されるため、古いコンテナが保持していたものは消えます。この損失はここでは許容できます。Table 1はcreator項目をちょうど1つ許し、Info文字列はすでに完全なリストを1塊で保持しており、より豊かなリストが必要な人のために元のパケットはソースファイルにまだ残っています

拒否は意図的です。XMPパケットがなければメソッドはEPdfErrorを投げます。完全なPDF/A識別とメタデータセットの構築はSaveAsPdfAの仕事で、PDFium ComponentでのPDF/Aアーカイブファイルの作成で扱います。読めないパケットや不正なInfo日付は、何かが書かれる前にEPdfXmpErrorを投げます。それらしい外見の間違った日付は、出力がないより悪いのです。署名付き文書は、呼び出し側がAllowSignedDocument = Trueを渡さない限りEPdfErrorを投げます。出力は新しいメタデータを増分更新として追記する完全保存であり、署名されたバイト列は無傷のまま持ち越されません

この保存経路にはもう1つ帰結があります。NormalizePdfAMetadataは、すべてのフィールドがすでに一致していても新しいファイルを書きます。何も同期しない同期でも、最後は保存で終わるからです。まずInspectPdfAMetadataを呼び、一致しているファイルはスキップしてください。何千もの無変更アーカイブを書き直す夜間ジョブが買うのは、新しいファイルタイムスタンプだけです。ファイルオーバーロードは一時ファイル経由で書き、ターゲットをアトミックに置き換えます。ソースファイルは文書がロードされている間開いたままなので、出力には別のパスを与えてください

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]);
      on E: EPdfError do         // AllowSignedDocumentなしの署名付き入力
        raise Exception.CreateFmt('Skipped %s: %s', [Source, E.Message]);
    end;
  finally
    Pdf.Free;
  end;
end;

等価性はISO 19005-1の1条項にすぎません。正規化されたファイルでも、フォント、色、透明度、その他すべてのPDF/Aチェックに合格しなければならず、ValidatePdfAは文書が長期保存へ出ていく前のゲートであり続けます

文書をロードせずにパケットを比較する

ComparePdfAInfoAndXmpとSynchronizePdfAInfoToXmpは、PDFを開かずにTPdfXmpPacketへ作用するFPdfPdfaのプレーンな関数です。文書が存在する前にテンプレートからXMPを満たすパイプラインやユニットテストに向いています。SynchronizePdfAInfoToXmpは書き直したフィールドのセットを返し、nilパケットを渡されるとEPdfXmpErrorを投げます

罠はPresentです。Default(TPdfAMetadataValues)で初期化されたレコードは空のセットを持ち、すべてのフィールドはpamsNotRequiredを報告し、どんな値を埋めても比較は通ります。レコードの文字列フィールドは値を保持します。標準が実際に要求するのがどれかを決めるのはPresentだけです

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''';
  // mandatory判定は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;

同じテンプレートを後でComparePdfAInfoAndXmpに通すと、空のMismatchFieldsが返るはずです。テンプレートを再生成するビルドステップにとって、安上がりのアサーションになります

他のシステムが編集し続けるアーカイブは、1フィールドずつずれていき、フィールドごとのレポートがどれが動いたかを示します。InspectPdfAMetadata、NormalizePdfAMetadata、PDF/A検証・変換ツールの残りは、PDFium Component for Delphi and C++Builderの一部です