技術記事

この PDF はどの規格を名乗るのか Delphi で検出する

検出コードは、他のプロデューサーの PDF/UA ファイルを何も名乗っていないと報告し、エディタが付箋を 1 個追記した瞬間に PDF/R スキャンは PDF/R でなくなります。HotPDF はロード済み文書からこれらの宣言を、IsLoadedPDFUA、GetLoadedPDFR1Version、GetLoadedPDFDeclarations といったアクセサで読み取ります。それぞれが、自分の担当規格が識別子を置いている場所を見るのです

各 PDF 規格はどこに識別子を書くのか

下位規格のどれも、宣言の置き場所について 1 つに合意したことがありません。ほとんどはカタログの /Metadata ストリームにある XMP プロパティを使い、古めのいくつかは今も文書情報辞書のキー頼みです。PDF/R はファイルトレイラーの隣にコメント行を置き、PDF 2.0 世代はさらに 2 つの機構を加えます。カタログのデベロッパー拡張辞書と、XMP 内の PDF Declarations です。識別子はファイル自身による自己申告です。中身がそれに見合うかを判断するのはバリデータの仕事で、準拠した PDF/A、PDF/X、PDF/UA 出力を作るのはそれ自体が 1 つの話題です。ルーティング(PDF/X なら印刷キュー、PDF/A ならアーカイブ、WTPDF ならアクセシビリティパイプライン)に要るのは宣言そのもの、正しい場所から読んだものです

規格宣言の場所HotPDF アクセサ
PDF/A (ISO 19005)XMP の pdfaid:part と pdfaid:conformance、PDF/A-4 ではさらに pdfaid:revIsLoadedPDFA、GetLoadedPDFAVersion、GetLoadedPDFARevision
PDF/X (ISO 15930)PDF/X-1a と PDF/X-3 は Info 辞書の GTS_PDFXVersion、後のパートは XMPIsLoadedPDFX、GetLoadedPDFXVersion
PDF/UA (ISO 14289)XMP の pdfuaid:partIsLoadedPDFUA、GetLoadedPDFUAVersion
PDF/VT (ISO 16612-2)XMP の pdfvtid:GTS_PDFVTVersionIsLoadedPDFVT、GetLoadedPDFVTVersion
PDF/E (ISO 24517-1)ISO_PDFEVersion、Info 辞書、カタログ、XMP のどこからでも読むIsLoadedPDFE、GetLoadedPDFEVersion
PDF/R-1 (ISO 23504-1)最後の startxref の真上にある %PDF-raster-x.y コメントIsLoadedPDFR1、GetLoadedPDFR1Version
PDF/VCR-1 (ISO 16613-1)カタログまたは XMP の GTS_PDFVCRVersionIsLoadedPDFVCR、GetLoadedPDFVCRVersion
WTPDF 1.0、ISO/TS 32005XMP の pdfd:declarations エントリIsLoadedWTPDF、GetLoadedWTPDFVersion、IsLoadedISO32005
PDF 2.0 デベロッパー拡張カタログの /Extensions /ISO_HasLoadedISODeveloperExtension、GetLoadedISODeveloperExtensionLevels
uses
  System.SysUtils, HPDFDoc;

procedure ReportClaims(const FileName: string; const Password: AnsiString);
var
  Pdf: THotPDF;
  PDFX, PDFXLevel: AnsiString;
begin
  Pdf := THotPDF.Create(nil);   // 検査専用のインスタンス
  try
    // パスワード付きオーバーロードはストリームを復号する。XMP パケットも含めて
    // プロンプトなしで開けるファイルには空のパスワードを渡す
    if Pdf.LoadFromFile(FileName, Password) <= 0 then
      raise Exception.CreateFmt('Cannot open %s', [FileName]);

    if Pdf.IsLoadedPDFA then
      Writeln('PDF/A: ', Pdf.GetLoadedPDFAVersion);        // 例:PDF/A-2b
    PDFX := Pdf.GetLoadedPDFXVersion;                       // XMP、次に Info
    if PDFX <> '' then
    begin
      PDFXLevel := Pdf.GetLoadedInfoKeyValue('GTS_PDFXConformance');
      if PDFXLevel <> '' then
        Writeln('PDF/X: ', PDFX, ', conformance ', PDFXLevel)
      else
        Writeln('PDF/X: ', PDFX);
    end;
    if Pdf.IsLoadedPDFUA then
      Writeln('PDF/UA: ', Pdf.GetLoadedPDFUAVersion);      // 例:PDF/UA-1
    if Pdf.IsLoadedPDFVT then
      Writeln('PDF/VT: ', Pdf.GetLoadedPDFVTVersion);
    if Pdf.IsLoadedPDFE then
      Writeln('PDF/E: ', Pdf.GetLoadedPDFEVersion);
    if Pdf.IsLoadedPDFR1 then
      Writeln('PDF/R: ', Pdf.GetLoadedPDFR1Version);       // 例:PDF/R-1.0
    if Pdf.IsLoadedWTPDF then
      Writeln('WTPDF: ', Pdf.GetLoadedWTPDFVersion);
  finally
    Pdf.Free;
  end;
end;

例にならって、検査には別の THotPDF インスタンスを使ってください。IsLoaded… の各関数は、コンポーネントが保持する文書なら何についてでも答えます。生成中の文書も含めてです。出力のために PDFUACompliance がオンにされていれば IsLoadedPDFUA は True になり、空でない PDFXCompliance があれば IsLoadedPDFX も同調します。生成の途中では便利ですが、両方の役割に使い回すコンポーネントでは、他人のファイルについての判定に自分の出力設定が混ざります

PDF/X は識別子がパート間を移動した唯一のファミリーです。PDF/X-1a と PDF/X-3 は GTS_PDFXVersion を文書情報辞書に置く PDF 1.3 プロファイルで、XMP はまったく要りません。PDF/X-4 以降のパートは XMP プロパティの pdfx:GTS_PDFXVersion を要求します。GetLoadedPDFXVersion はまず XMP を、pdfx と pdfxid のどちらの接頭辞でも探し、次に Info 辞書、さらに一部のレガシープロデューサーが書くカタログキーを調べ、宣言された値をそのまま返します。PDF/X-1a にはコードで面倒を見る価値のある癖が 1 つあります。バージョン値は PDF/X-1:2001 で、PDF/X-1a:2001 の適合レベルは専用のキー /GTS_PDFXConformance に住んでいます。例は X-1a をただの X-1 と区別する必要が生じたとき、これを GetLoadedInfoKeyValue で読みます

各 PDF 規格が適合識別子を書く場所と、Delphi の HotPDF がロード済み PDF の XMP と Info 辞書、カタログキーを探索する方法
各規格は仕様が選んだ場所に宣言を格納し、リーダーは宣言が移動し得る場所を固定順で探索します

有効な XMP 宣言はどんな形か

XMP は XML にシリアライズされた RDF で、RDF/XML は単純プロパティを rdf:Description の中に 2 通りで書くことを許します。子要素としての <pdfuaid:part>1</pdfuaid:part>、あるいは記述自身の属性としての pdfuaid:part="1" で、一重引用符でも二重引用符でも構いません。ISO 16684-1 は両方を受け入れ、パケットはプロパティを複数の rdf:Description 要素に分けてもかまいません。この分割は仮定の話ではありません。HotPDF は PDF/UA、PDF/VT、PDF/E、PDF/X に対して子要素形式で書く一方、veraPDF のテストコーパスや多くの外部プロデューサーは省略形で書き、pdfuaid:part="1" や pdfvtid:GTS_PDFVTVersion="PDF/VT-1" のような記述が宣言全体を属性で運びます。Pos('<pdfuaid:part>', Xmp) を軸に組み立てられ、自社出力だけで鍛えられた検出器は、手元のチェックを全部通った後、最初の veraPDF サンプルを未宣言と呼んでしまいます。HotPDF の XMP リーダーは表の中のどのアクセサでも両形式を扱います。GetLoadedPDFAVersion は pdfaid:part と pdfaid:conformance を PDF/A-1b、PDF/A-2u、PDF/A-4、PDF/A-4f のような識別子へ組み合わせ、GetLoadedPDFARevision は pdfaid:rev、つまり PDF/A-4 が要求し(2020)パート 1 から 3 が定義しない版年を返します

procedure ReportPDFALevel(Pdf: THotPDF);
var
  Level, Revision: AnsiString;
begin
  Level := Pdf.GetLoadedPDFAVersion;      // 'PDF/A-1b'、'PDF/A-2u'、'PDF/A-4f' など
  if Level = '' then
    Exit;
  Revision := Pdf.GetLoadedPDFARevision;  // '2020' は PDF/A-4 の版、パート 1〜3 では空
  if Revision <> '' then
    Writeln(Level, ', edition ', Revision)
  else
    Writeln(Level);
  // 期待値:プロデューサーがどちらで書いても "PDF/A-2b" になる
  // <pdfaid:part>2</pdfaid:part> でも pdfaid:part="2" でも
end;

接頭辞はもう 1 つの動く部分です。XML では接頭辞はローカルな別名にすぎません。PDF/A スキーマを識別するのは名前空間 URI の http://www.aiim.org/pdfa/ns/id/ で、承認済みの ISO 19005-4 エラッタは https:// 綴りも認めており、プロデューサーは好きな接頭辞に束縛してかまいません。GetLoadedPDFAVersion はパートとレベルを読む前に、パケットがその URI について宣言する接頭辞を引き、pdfaid:part の代わりに pdfa:part と書いたパケットでも識別されます。GetLoadedXMPMetadata が返すデコード済みパケットから自分で読む他の XMP プロパティにも同じ注意が要ります。2 つのシリアライズ形態、xmlns 宣言から解決する接頭辞、等号の周りに許される空白です

1 つの PDF/UA 宣言の 2 つの有効な XMP シリアライズと、接頭辞を改名しても Delphi の HotPDF に PDF/A と識別させる名前空間のルール
子要素と属性の綴りはどちらも等しく有効で、スキーマを識別するのは接頭辞ではなく名前空間 URI です

暗号化ファイルはバイト検索から宣言を隠す

生ファイルを pdfaid や ISO_PDFEVersion で検索するやり方はパースを省けるぶん速いのですが、ありふれた 2 つのケースで壊れます。ほとんどの XMP ストリームは Flate 圧縮されており、プロパティ名はディスク上にテキストとして現れません。さらに暗号化文書ではメタデータストリームは暗号文です。ISO 32000-2 §7.6.4.2 は EncryptMetadata をデフォルト true で定義するので、プロデューサーが明示的に外さない限り、パケットは他のストリームと一緒に暗号化されます。したがって暗号化された PDF/E-1 ファイルへのバイト検索は、ISO_PDFEVersion の宣言がそこにあるのに欠けていると報告します。頼れる順序はロード、復号、デコード、それから検索です。パスワード付きオーバーロードの LoadFromFile(FileName, Password) でファイルを開き、オーナーパスワードだけを持ちどのビューアでもプロンプトなしで開けるファイルには空文字列を渡します。単引数の LoadFromFile(FileName) はストリームを復号せずに文書構造をロードするので、XMP パケットは読めないままです。パスワード付きオーバーロードの後なら、GetLoadedXMPMetadata と上のアクセサはどれも、復号と解凍を終えたパケットの上で動きます

PDF/R ファイルはなぜ編集の後に正体を失うのか

PDF/R-1 は他の下位規格とは違うやり方で自己を識別します。ISO 23504-1 の箇条 5 は、startxref を載せる最後の行の真上の行に、%PDF-raster-1.0(または該当するバージョン)というコメント行を要求します。PDF パーサは相互参照データを読む間にコメントを捨てるので、オブジェクトモデルは報告すべきものを持ちません。証拠はファイル末尾の生バイトにしか存在しないのです。GetLoadedPDFR1Version は文書のロード元ソースに戻り、末尾 64 KiB を上限に読み、最後の startxref を見つけ、その上の行を確認します。ここで増分更新を追ってください。注釈を追加するエディタは、元の %%EOF の後に新しいオブジェクトと新しい相互参照セクション、新しいトレイラー、新しい startxref を追記します。%PDF-raster-1.0 の行はまだファイルにありますが、今度は最後から 2 番目の startxref の上に座っており、どちらが有効かについて規格は曖昧さを残しません。期待値はこうです。編集の後、編集ツールが自分の更新でコメントを再出力しない限り IsLoadedPDFR1 は False を返します。ファイル全体を %PDF-raster- で検索する検出器は yes と言うでしょうが、箇条 5 のルールではその答えは間違いです。ファイルを PDF/R のまま保たねばならないキャプチャワークフローは、注釈をサイドカーファイルへ置くか、ファイル全体を PDF/R として書き直すツールで作業します

増分更新の前後のファイル末尾。PDF/R コメントは残るが、Delphi の HotPDF が確認する最後の startxref アンカーを失う
更新を追記するエディタは PDF/R コメントを最後から 2 番目の startxref の上に残します。そこは規格がもう数えない場所です

デベロッパー拡張:整数のレベルと名前、配列

PDF 2.0 文書は、依存する ISO 技術仕様(ISO/TS 32001 の SHA-3 ハッシュ、ISO/TS 32002 の ECC 署名、ISO/TS 32007 の glTF アセット)を、ISO 32000-2 §7.12 で定義されるカタログの /Extensions 辞書で公表します。登録済み接頭辞 /ISO_ の下で、Table 48 は単一のデベロッパー拡張辞書か、PDF 2.0 で新しくなったその配列のどちらも許し、Table 49 は §7.12.5 とともに /ExtensionLevel を整数にします。2 つの容器形状のうち片方しか扱えないリーダーは、もう片方で書かれたファイルをすべて取りこぼします。HasLoadedISODeveloperExtension と GetLoadedISODeveloperExtensionLevels は両方を受け入れ、途中で間接参照も解決します。§7.12.1 はこれらの辞書を直接オブジェクトであれと言うのですが

procedure ReportISOExtensions(Pdf: THotPDF);
var
  Levels: THPDFIntegerArray;
  Revision, URL: AnsiString;
  I: Integer;
begin
  Levels := Pdf.GetLoadedISODeveloperExtensionLevels;
  for I := 0 to High(Levels) do
    Writeln('ISO_ extension level ', Levels[I]);

  if Pdf.HasLoadedISODeveloperExtension(32001, Revision, URL) then
    Writeln('ISO/TS 32001 revision ', Revision, ', documented at ', URL);
  // Expected for a file written with EnsureISO32001Extensions:
  //   ISO/TS 32001 revision :2022, documented at https://www.iso.org/standard/45874.html
  if Pdf.HasLoadedISODeveloperExtension(32007) then
    Writeln('glTF 3D assets per ISO/TS 32007');
end;

ISO/TS 32007:2024 の初版は §4.8 に /ExtensionLevel /32007 を名前で書く例を持っており、PDF Errata Issue #835 が整数の 32007 に直したものの、元の例を写したプロデューサーは今も名前で書きます。それで HotPDF のリーダーは、整数での引きが空に戻ってきたとき、先頭のスラッシュの有無にかかわらず名前や文字列値のパースにフォールバックします。HasLoadedISODeveloperExtension(32007) は両方の綴りを見つけられます。もう 1 つのエラッタは /URL に関わります。ISO 32000-2:2020 の Table 49 は /URL を任意として載せ、Issue #732 が必須に引き上げました。書き込み側の HotPDF はすべての EnsureISO…Extensions 呼び出しで整数形式と /URL を出力します。読み込み側では、2020 年のテクストどおりに書かれたファイルでは空の URL out パラメータが普通のことなので、情報として扱い、拡張を拒む理由には決してしないでください

プロデューサーはどの WTPDF URI を書いたのか

WTPDF 1.0 と ISO/TS 32005 の宣言は PDF Declarations です。XMP の pdfd:declarations バッグの中のエントリで、PDF Association の PDF Declarations テクニカルノートが定義する名前空間 http://pdfa.org/declarations/ の下に置かれます。各エントリは conformsTo URI と、任意の claimBy、claimDate、claimCredentials、claimReport フィールドを運び、GetLoadedPDFDeclarations はこれを THPDFDeclarationEntry レコードの配列で返します。書き込み側では AddPDFDeclaration が同じ 5 つのフィールドを XMP パケットへ入れ、PDF 2.0、PDF/A-4、PDF/UA-2 の識別名前空間の隣に置きます。バッグを素のテキスト検索でエントリに切り分けることには固有の罠があります。conformsTo の出現ごとに切り分けると、子要素形式の閉じタグ </pdfd:conformsTo> が新しいエントリの始まりと数えられ、最初の宣言が自分の終わりの前で切り詰められます。HotPDF のリーダーは閉じタグを飛ばして、両方のシリアライズを受け入れます

URI 自体のほうが厄介です。同じ宣言が http:// で届いたり https:// で届いたりし、WTPDF 1.0 Issue #395 は # の前に / が座るかどうかを変えました。エラッタの前後で書かれたファイルはその 1 文字で異なり、素の文字列比較は両者を別の規格として扱います。HasLoadedPDFDeclaration は URI を大文字小文字を無視して比較し、https:// を http:// に畳み、両側で # の真前の / を落とします。だから下の問い合わせは、プロデューサーがどちらの綴りを選んでいても一致します。フラグメントは無傷のままです。#accessibility1.0 と #accessibility1.0-validated は別の宣言で、後者はバリデータがファイルを検証したと主張するものです。ワークフローが気にするほうを問い合わせてください。GetLoadedWTPDFVersion はルーティングのために両方を 'WTPDF 1.0 Accessibility' か 'WTPDF 1.0 Reuse' に畳みます

procedure ReportDeclarations(Pdf: THotPDF);
var
  Decls: THPDFDeclarationEntryArray;
  I: Integer;
begin
  Decls := Pdf.GetLoadedPDFDeclarations;
  for I := 0 to High(Decls) do
    Writeln(Decls[I].ConformsTo, ' claimed by ', Decls[I].ClaimBy,
      ' on ', Decls[I].ClaimDate);

  // http と https のどちらにも一致。'#' の前のスラッシュはあってもなくても
  if Pdf.HasLoadedPDFDeclaration(
    'https://pdfa.org/declarations/wtpdf#accessibility1.0') then
    Writeln('WTPDF 1.0 Accessibility (unvalidated claim)');

  // Reuse と Accessibility の通常版と -validated 版の両方をカバー
  Writeln('WTPDF profile: ', Pdf.GetLoadedWTPDFVersion);
end;

キャプチャや印刷ルーターでは、操作の順序がどの単独アクセサよりも大事です。IsLoadedPDFR1 は、注釈やスタンプのステップが更新を追記する前の、届いたままのファイルで試し、PDF/X の振り分けは GetLoadedPDFXVersion で行います。これは PDF/X-1a と PDF/X-3 のジョブの Info 辞書も PDF/X-4 のジョブの XMP も等しくカバーし、/GTS_PDFXConformance を読むのは X-1a をただの X-1 と区別する場所だけです。2 つのチェックはどちらも、パスワード付きで開いた暗号化ファイルを含め、HotPDF Delphi PDF component にロードされたどんな文書でも動きます