DelphiからPDF/A-3文書へソースファイルを添付するために、PDFium ComponentはPDF 2.0のassociated-fileチェーンを書きます。MIMEの/Subtypeを持つ埋め込みファイルストリーム、/AFRelationshipを運ぶファイル仕様、そしてカタログかページに吊るされた/AF配列です。InjectAssociateFilesとTPdf.SaveAsWithAssociateFilesはそのチェーンを1回のインクリメンタルアップデートで組み立て、v3.121.2以降、MIMEタイプは正しくエスケープされた1つのPDF名として直列化されます。この記事の残りでは、バリデーターが何を検査するか、text/plainを壊した1文字のバグ、そして旧リリースが黙って依頼と違うことをしていた場所を扱います
PDF/A-3のassociated fileに実際必要なもの
PDF/A-3の添付が検証を通るのは、3つのオブジェクトが互いに一致するときだけです。埋め込みファイルストリームが/Type /EmbeddedFileとMIMEの/Subtypeを宣言し、ファイル仕様辞書(ISO 32000-2 §7.11.3)が/F、/UF、/EF、/AFRelationshipを運び、文書のどこかがそのファイル仕様を/AF配列(ISO 32000-2 §14.13)経由で参照する。/Names /EmbeddedFilesツリー経由の素の埋め込み、つまりTPdf.CreateAttachmentがやっていることでは、関連付けフィールドは一切立ちません。PDFium Component自身のPDF/A-3b検証フィクスチャがこの依存を具体的に示します。/AFRelationshipキーの名前だけを変えると、ファイルはISO 19005-3の条項6.8の規則をちょうど1つだけ落とします。MIMEの/Subtypeだけを落とすと、別の6.8の規則が落ちます。同じ添付をPDF/A-1b候補に入れると丸ごと拒否されます。PDF/A-1はメタデータがどれだけ整っていようと、埋め込みファイルを禁じているからです
関係値は人が当てずっぽうになりがちな部分です。FPdfAssocFilesのTPdfAFRelationshipは、インジェクターが出力し得る各ネームトークンに列挙メンバーを1対1で対応させます。そして最初の5つだけが、ISO 19005-3が認めるサブセットに属します
afSource→/Source:PDFの生成元になった原本。ワープロファイルやスプレッドシートなどafData→/Data:見える内容の派生元であり、見える内容が表している機械可読データafAlternative→/Alternative、afSupplement→/Supplement、afUnspecified→/UnspecifiedafEncryptedPayload、afFormData、afTemplate:PDF 2.0の追加で、PDF/A-3サブセットの外です。アーカイブ出力には持ち込まないこと
/Subtype /text/plainが検証を壊した理由
MIMEのバグはトークン化の誤りであって、適合の欠落ではありませんでした。v3.121.2より前、インジェクターは呼び出し元の文字列をスラッシュの直後に連結し、/Subtype /text/plainを作っていました。PDF構文では2つ目のスラッシュは新しい名前オブジェクトの始まりです(ISO 32000-1 §7.3.5)。ストリーム辞書は突然、キーの/Subtype、名前の/text、そしてキーと値の対の均衡を崩す余分な名前/plainを抱え込みました。独立系のPDF/AバリデーターはEmbeddedFile辞書をパースしている最中にファイルを拒否し、PDF/Aの規則にはとうてい辿り着きません。だから障害は、欠けた添付プロパティではなく、ファイル破損に見えたのです
修正はMIME値をEscapePdfNameへ通し、/text#2Fplainを出力します。デコード値がtext/plainである1つの名前です。エスケープはスラッシュより意図的に広く、32以下のバイト(空白、タブ、CR、LF)、127以上のバイト、区切り文字の()<>[]{}/%、そしてエスケープ文字の#そのものが#XXになります。スラッシュだけのエスケープでは別の穴が残りました。>>や空白を含むMIME文字列は、辞書を早く閉じたり余分なキーを注入したりできたはずです。だからリグレッションテストは、全区切り文字にタブ、LF、CRを足した敵対的な値を流し込み、符号化出力の正確な一致を確かめます
// インジェクターがMIMEType = 'text/plain'に対して書くもの
// v3.121.2より前: /Type /EmbeddedFile /Subtype /text/plain (2つの名前)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (1つの名前)
//
// 呼び出し元は常に普通のMIME値を渡すこと。自分で先にエスケープすると
// '#'が二重に符号化され、'text#2Fplain'は'text#232Fplain'に化ける
Options.Files[0].MIMEType := 'text/plain';
InjectAssociateFilesでPDF/A-3ファイルを組み立てる
PDF/A-3出力では、適合するベース文書をTPdf.SaveAsPdfAToStreamで作り、そのストリームへInjectAssociateFilesを呼びます。検証フィクスチャがPDF/A-3bを通す前に走らせているのは、まさにこの2段パイプラインです。TPdf.SaveAsWithAssociateFilesは便利ラッパーですが、保存はPDF/Aライターではなく、saRemoveSecurity付きの普通のSaveAs経路で行うため、PDF/Aが要求するXMP識別と出力インテントは加わりません。レコード型はFPdfAssocFilesとFPdfPdfaに住んでいるので、両ユニットをuses節に入れてください。v3.121.3以降、FileNameとDescriptionは素のASCIIでなくて構いません。/UFと/DescはPDFテキスト文字列として書かれ、印字可能ASCIIはそのまま、それ以外はバイト順マーク付きのUTF-16BEです。一方レガシーの/F名は常に持ち運べる印字可能ASCIIで、他の文字はすべて_へ置き換わります。独自のコードページで/Fをデコードするリーダーも、文字化けではなくアンダースコアを見るわけです。それより前のビルドは3つとも、DelphiではシステムANSIコードページ経由で変換し、Free Pascalでは生のUTF-8バイトを書いていました。旧ビルドと同じ出力を強制されるなら、名前をASCIIに保つしかありません
uses
System.SysUtils, System.Classes, System.IOUtils,
PDFium, FPdfPdfa, FPdfAssocFiles;
procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
PdfAOptions: TPdfASaveOptions;
Options: TAssocFilesOptions;
Base: TMemoryStream;
Output: TFileStream;
begin
PdfAOptions := TPdfASaveOptions.Default;
PdfAOptions.Conformance := pac3b;
Options := TAssocFilesOptions.Default; // TargetPage = 0:カタログレベルの/AF
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'invoice-data.xml';
Options.Files[0].Description := 'Structured invoice data';
Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
Options.Files[0].Relationship := afData;
Options.Files[0].MIMEType := 'application/xml'; // /application#2Fxmlとして書かれる
Base := TMemoryStream.Create;
try
if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
raise Exception.Create('PDF/A-3 base save failed');
Output := TFileStream.Create(OutPath, fmCreate);
try
InjectAssociateFiles(Base, Output, Options); // Baseを巻き戻す。失敗時はEPdfAssocFilesError
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
カタログかページか:/AF配列の着地点
TAssocFilesOptions.TargetPageが/AF配列の所有者を決めます。0はカタログへの文書レベル関連付け、1..Nはそのページ辞書への関連付けで、1始まりです。インジェクターはすべてを、固定レイアウトの単一インクリメンタルアップデートとして追記します(埋め込みストリーム、次にファイル仕様、次に/AF配列、最後に書き直されたカタログかページオブジェクト)。既存オブジェクトはオフセットを保ち、再圧縮は起こりません。対象辞書に既にある/AFエントリはマージではなく置き換えです。繰り返し保存がべき等になる半面、別のファイルリストでの2回目の呼び出しが勝つことを意味します。かつては自分のコードでガードする価値のある挙動が2つあり、両方とも変わりました。v3.122.0より前は、範囲外のTargetPageは失敗しませんでした。カタログへフォールバックしたので、タイプミスがページレベルの関連付けを、何の合図もなく文書レベルへ変えていました。v3.122.0以降、SaveAsWithAssociateFilesとSaveAsWithAssociateFilesToStreamは、TargetPageが0..PageCountの外にあるときEPdfErrorを投げ、InjectAssociateFilesは負のTargetPageや実在しないページを名指す値に対して、新しいEPdfAssocFilesErrorを投げます。出力ストリームは無変更のままです。さらにv3.121.4より前、ページ探索は保存済みバイトをファイル順に/Type /Page辞書で走査していました。ページオブジェクトが表示順と違う順で格納されると、ページの並べ替えや挿入の後などに、ファイルが別のページに付くことがあり得ました。v3.121.4以降、TargetPageは文書ページ順のその位置にあるページを名指します
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// v3.122.0以降、範囲外のTargetPageはEPdfErrorを投げる(旧ビルドは
// 黙ってカタログレベルの/AFへフォールバック)。先に確認すればページを名指せる
if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);
Options := TAssocFilesOptions.Default;
Options.TargetPage := PageNumber;
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'chart-data.csv';
Options.Files[0].Content := CsvBytes;
Options.Files[0].Relationship := afSource;
Options.Files[0].MIMEType := 'text/csv';
if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
raise Exception.Create('Associated-file save failed');
end;
AFRelationshipを確実に読み戻す方法
TPdf.AttachmentRelationship[Index]はネイティブのFPDFAttachment_GetAFRelationshipエクスポート経由で添付の/AFRelationship名を返しますが、空文字列には2通りの意味があり得るので、まずAttachmentRelationshipFeaturesAvailableを呼んでください。バインディングは寛容にロードされます。PDFium DLLにそのエクスポートがなければ、すべての関係は空として読めます。それは単に/AFRelationshipを持たないファイル仕様と区別がつきません。このプロパティはインデックスをAttachmentCountと共有します。これは/Names /EmbeddedFilesツリーのエントリを数えます。インジェクターは/AFチェーンだけを書き、ネームツリーエントリは足さないので、InjectAssociateFiles経由で添付されたファイルはそのインデックスの外にいます。注入チェーンの確認は、保存済みバイトの検査かPDF/Aバリデーターで。ネームツリーの内部はPDFium ComponentでDelphiのPDF添付ファイルを扱うで扱っています
procedure ReportRelationships(const FileName: string);
var
Pdf: TPdf;
I: Integer;
Rel: string;
begin
if not AttachmentRelationshipFeaturesAvailable then
begin
Writeln('This PDFium build cannot report /AFRelationship');
Exit; // 空の答えは曖昧になるので、尋ねない
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for I := 0 to Pdf.AttachmentCount - 1 do
begin
Rel := Pdf.AttachmentRelationship[I];
if Rel = '' then
Rel := '(no /AFRelationship)';
Writeln(Pdf.AttachmentName[I], ': ', Rel);
end;
finally
Pdf.Free;
end;
end;
SaveAsWithAssociateFilesが保証しないもの
TPdf.SaveAsWithAssociateFilesが保証するのはファイル形式の封筒と、要求されたファイルが注入されたことです。適合は保証しません。注入の部分は新しい挙動です。v3.122.0より前、保存済みバイトに読めるtrailerがなかったり、カタログ辞書が見つけられなかったりすると、InjectAssociateFilesは入力をそのままコピーし、メソッドはTrueを返していました。v3.122.0以降、InjectAssociateFilesはそれらのケースで何かを書く前にEPdfAssocFilesErrorを投げ、SaveAsWithAssociateFilesはFalseを返します。しかも出力全体を保存ストアで組み立ててからターゲットを開くようになったので、拒否や失敗の保存が既存ファイルを切り詰めることもありません。空のFiles配列は設計どおり、文書を無変更でコピーし続けます。ペイロードの中身もあなたの責任です。インジェクターはXMLファイルが整形式か、MIMEタイプがバイト列と一致するか、ベース文書がそもそもPDF/Aかを検査しません。バリデーターが見るまで、最終ファイルは未検証として扱ってください。PDFium ComponentとPDF/Aアーカイブ適合で述べたのと同じ規律です。入ってくる辞書を自分でもパースするなら、同じ#XXネーム規則が逆向きに効きます。PDF辞書パース時のネームトークンの罠で扱う話題です
associated file、PDF/A出力、添付メタデータ、検証はすべて同じコンポーネントに搭載されています。上のパイプラインは、ビルドに2つ目のPDFライブラリを必要としません。APIリファレンス、トライアルダウンロード、ライセンスはPDFium Component製品ページをご覧ください