/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/):単純テキストプロパティ
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として報告されます
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プロパティは放置され、既存の接頭辞束縛はプロパティの書き戻しで再利用されます
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の一部です