技術記事

PDF/Aの実装上限とフォントエンコーディング検査

請求書アーカイブがきれいにレンダリングし、印刷でき、全フォントを埋め込み、有効な出力インテントを運んでいても、PDF/Aバリデータは拒否します。1つのリソース辞書が4,096エントリを保持しているからです。上限は4,095です。PDF/AはPDFリファレンスから小さな実装上限の表を引き継いでおり、そのどれかを1単位でも超えたファイルは、ビューアが一切文句を言わなくても、適合性に不合格となります

同じ「でも見た目は問題ない」枠に着地するもう1つの失敗が、/Encodingエントリを運ぶシンボリックTrueTypeフォントです。チェックボックス、バーコード、絵文字フォントは画面上で正しく出ますが、バリデータはやはりフォント辞書に旗を立てます。どちらの問題も、ページ上で人が見える何かではなく、オブジェクトグラフの中に住んでいるため、ファイルがアーカイブへ入る前に、機械的に検査する価値があります

PDF/Aが引き継ぐ数字と、境界の位置

PDFium Componentは、すべてのPDF/A検証で4つのコンテナ上限を検査します。名前トークンは最大127バイト、配列は最大8,191要素、辞書は最大4,095エントリ、配列と辞書のネストは最大28段階です。4つの境界はすべて包含的です。127バイトの名前は合格し、128バイトは不合格です。ちょうど8,191要素の配列は合格し、1つでも多くの要素があれば不合格です

名前、配列、辞書の上限は、適合リーダーがそのサイズまでの構造を処理すればよいだけだった時代の産物です。現行のデスクトップビューアは、ずっと大きな配列を文句なしに開けます。しかしアーカイブファイルは、まだ誰もテストしていないリーダー、記録管理システムがまだ走らせているかもしれない古いものを含め、そこで開けるはずのものです。だからPDF/Aは、緩める代わりに歴史的な上限を保持しているのです

包含と排他の境界こそ、手作りの検査がたいてい間違える場所です。Count > 8191ではなくCount >= 8191と書いたテストは、適合しているファイルを拒否します。そして適合ファイルを拒否するバリデータは、それ自体が信じられがちなため、ジェネレーターのほうが、一度も犯していない問題のために「修正」されます。FPdfPdfaユニットのValidatePdfACompliance関数は、上限を厳密に超えたときだけ数え、ファイルのバイト列を直接読むため、この検査はPDFium DLLをロードせずに走ります

PDFium Component が Delphi からバイトスキャンで検査する 4 つの包含的コンテナ上限の図。127 バイトの名前トークン、8191 の配列要素、4095 の辞書エントリ、28 段階の配列と辞書のネスト
4つの上限はすべて包含的です。境界値そのものは合格し、次の1単位だけが不合格になります
uses
  System.Classes, FPdfPdfa;

procedure CheckContainerLimits(const FileName: string; Log: TStrings);
var
  Src: TFileStream;
  Res: TPdfAValidationResult;
begin
  Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Res := ValidatePdfACompliance(Src);   // バイトスキャン。PDFium DLLは不要
    if pvaiNameOverLimit in Res.Issues then
      Log.Add('A name token is longer than 127 bytes');
    if pvaiArrayOverLimit in Res.Issues then
      Log.Add('An array holds more than 8191 elements');
    if pvaiDictOverLimit in Res.Issues then
      Log.Add('A dictionary holds more than 4095 entries');
    if pvaiNestingOverLimit in Res.Issues then
      Log.Add('Arrays and dictionaries nest deeper than 28 levels');
    if Res.IsCompliant then
      Log.Add('No PDF/A findings');
  finally
    Src.Free;
  end;
end;

4つのフラグは、他のすべてのPDF/A所見と並んで、TPdfAValidationResultのIssues集合のメンバーであり、IsCompliantは、PDF/Aの識別が見つかり、集合が空のときにだけTrueを返します。PDF/Aの識別を一切運ばないファイルは、厳密なPDF/A-1の規則集合に対して検査されるため、コンテナ上限はやはり適用されます

どのジェネレーターがこれらの上限に当たるのか

ほぼいつも、ループで構造を組み立てるものです。数千フィールドのフォームは、8,191エントリを超えて歩くAcroFormの/Fields配列、あるいはページの/Annots配列を生みます。生成オブジェクトごとに1つのフォントや画像リソースを登録し、既存のエントリを決して再利用しないレポートエンジンは、/Resourcesサブ辞書を4,095キーを超えて育てます。ネストしたデータモデルを再帰して構造ツリーを生むタグ付きドキュメントは、誰も見ないうちに28段階を越えます

長い名前の起源は別です。データを名前トークンへ詰め込むことです。顧客参照から名付けられた分離色材、ファイルパスから名付けられたオプションコンテンツグループ、6階層の階層から組み立てられたフィールドラベルです。名前は生成にコストが掛からず、アクセント付き文字を含むUTF-8ラベルが絡むと、127バイトはすぐに尽きます。それらの文字は1つに2〜3バイト取られるからです。127バイトは、/の紹介子の後、次の区切り文字か空白までの間で数えられます

救済は、いつも構造的です。配列を分ける、辞書を分ける、再帰を平坦化する、名前を短くする。ファイルにPDF/Aマーカーを注入しても、これらの所見のどれにも役立ちません。問題はオブジェクトグラフの形状であり、XMPメタデータはそれについて何も語らないからです

スキャンが要素とエントリを数える方法

数える規則が問題になるのは、hexエディタで所見を再現しようとするときです。ネストした配列や辞書は、親の1つの要素として数えられます。上限の述べ方そのものです。1,000要素の配列からなる10要素の配列は問題ありません。8,191を超える単一の配列が存在しないからです。辞書の内側ではキーだけが数えられるため、たまたま名前である値は、エントリ合計を膨らませません。<FEFF0041>のようなhex文字列は単一トークンとしてスキップされるため、その山括弧が辞書のネストと誤認されることはありません

スキャンはファイル全体のオブジェクト構造を覆い、圧縮オブジェクトストリームの内側に保存されたオブジェクトも、数える前に展開されます。ページコンテンツや画像データといったストリーム本体は先に空白へ潰されるため、たまたま/や[を含むバイナリバイトの連なりが、偽の名前や配列の所見を引き起こすことはありません

同じ検証が、独自のフラグで報告する、隣接する数値上限があります。ファイルがPDF/A-2以降のパートを宣言しているとき、32ビット符号付き範囲の外、つまり-2,147,483,648から2,147,483,647の外にある整数トークンに対してpvaiIntegerImplementationLimitが立ちます。バイトオフセットや大きなカウンタを素の整数として書くジェネレーターが、いつもの源です

シンボリックTrueTypeフォントが/Encodingを落とさねばならない理由

ISO 19005-1 §6.3.7は、シンボリックTrueTypeフォントの文字マッピングとして、フォントプログラムの組み込みcmapだけを認めます。シンボリックフォントは、自分の流儀でコードをグリフへマップします。それがSymbolicフラグの宣言するところです。/Encodingエントリを加えると、ファイルは「バイト0x41はどのグリフを選ぶか」という問いに2つの答えを与え、どちらが勝つかを告げるものがファイルのどこにもなくなります。リーダーは競合をそれぞれ違う形で解決し、同じチェックボックスがあるビューアではチェックになり、別のビューアでは文字Aになります

ValidatePdfAComplianceは、/Subtype /TrueTypeを持つすべてのフォント辞書を見つけ、/Encodingキーを持つかを検査し、それからフォントディスクリプタの/Flagsエントリのビット3(値4)を読みます。ディスクリプタは、インラインで書かれていようが間接オブジェクトとして参照されていようが、追跡されます。/WinAnsiEncodingや/MacRomanEncodingを保持する非シンボリックTrueTypeフォントは、この検査で旗を立てられません。非シンボリックフォントに対してPDF/Aが期待するのは、まさにそのエンコーディングだからです

シンボリック TrueType フォントの /Encoding エントリが 2 つの競合するグリフマッピングを生むことと、合法的に WinAnsiEncoding を保持する非シンボリックフォントとを対比する、Delphi の PDFium Component 検査の図
この検査は、シンボリックフラグをフォントディスクリプタから読み、エンコーディングの存在ではなく、矛盾に対して発火します
// Res comes from ValidatePdfACompliance as in the first example
if pvaiSymbolicTrueTypeEncoding in Res.Issues then
  Log.Add('A symbolic TrueType font carries /Encoding; PDF/A admits only ' +
    'the font''s built-in cmap (ISO 19005-1 6.3.7)');

// A neighboring font rule with a different trigger: a NON-symbolic
// TrueType font whose Encoding dictionary carries /Differences
if pvaiTrueTypeDifferencesForbidden in Res.Issues then
  Log.Add('A non-symbolic TrueType Encoding dictionary carries /Differences');

この規則に引っ掛かるフォントは、予測可能です。Symbol、Wingdings、バーコードフォント、アイコンフォントです。業務ドキュメントにはチェックボックス、評価の星、バーコードとして現れ、すべてのTrueTypeフォントを同じように扱うサブセット化ステップは、これらにもエンコーディングを書き込みます。そしてバリデータが「フォントの問題」と告げたとき、誰も見ないフォントでもあります。本文のフォントから先に検査されるからです

逆の間違いも、実地に現れます。本当にWinAnsiエンコードされている普通のテキストフォントに、プロデューサーがSymbolicフラグを立てるケースです。PDF/A検査は条項の書かれたとおりに追い、そのフォントを報告します。この場合の修理は、ディスクリプタの側に属します。エンコーディングを消すのではなく、ディスクリプタのフラグを直してください。誤ラベルされたテキストフォントから/Encodingを取り除くと、テキストが、サブセットがたまたま含むcmapに依存する状態を残します。出発点より悪いファイルです。矛盾のどちら側を消すか選ぶ前に、フォントのグリフ集合を見てください

所見をプレフライトレポートで読む

プレフライトレポートのユニットはEnterpriseエディションに同梱されます。一方、ValidatePdfAComplianceは全エディションにあります。FPdfPreflightReportユニットのレポートは、4つのコンテナ上限をStructureカテゴリへ、シンボリックTrueTypeエンコーディングの問題をContentカテゴリへ振り分けます。この振り分けは意図的なものです。Structureの所見はたいていジェネレーターの保守者へ戻り、Contentの所見はたいていフォントとテンプレートの提供者へ向かいます。両者を混ぜたレポートは、両者に、自分の領分ではない問題の追跡を命じます

各問題は、enum名をCodeとして運び、救済を具体的な言葉で名指す推奨を伴います。たとえば「Split dictionaries so none carries more than 4095 entries」です。Structureカテゴリには他に2つの所見、つまりLevel Aの主張におけるタグ付き構造の欠落と、PDF/A-1ファイルにおけるPDF 1.5のクロスリファレンスやオブジェクトストリームも住んでいるため、4つの上限だけが欲しいときはコードでフィルタしてください

uses
  System.SysUtils, System.Classes, PDFium, FPdfPreflightReport;

procedure ReportStructureFindings(const FileName: string; Log: TStrings);
var
  Pdf: TPdf;
  Report: TPdfPreflightReport;
  Issues: TPdfPreflightIssues;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;          // この経路はPDFium DLLをロードする
    if not Pdf.Active then
      Exit;
    Report := BuildPdfPreflightReport(Pdf, [ppsPdfA]);
    Issues := Report.FilterByCategory('Structure').FlattenIssues;
    for I := 0 to High(Issues) do
      Log.Add(Issues[I].Code + ': ' + Issues[I].Recommendation);
    Report.SaveJsonToFile(ChangeFileExt(FileName, '.preflight.json'));
  finally
    Pdf.Free;
  end;
end;

2つの経路は、少し違う問いに答えます。BuildPdfPreflightReportはTPdf.ValidatePdfAを通ります。これは開いているドキュメントを一時コピーへ保存し、同じバイトスキャンをそのコピーで走らせ、それからPDFiumエンジンを要する検査、たとえばページで使われているのに埋め込まれていないフォントを加えます。ValidatePdfAComplianceは、ディスク上の正確なバイト列を判定します。実装上限については、オリジナルファイルに対するバイト経路こそ信じるべきものです。アーカイブしようとしているそのファイルについて報告し、ネイティブDLLをデプロイできないビルドステップやロックダウンされたサービスでも走るからです。エンジンレベルの検査と、パイプライン向けのJSON、SARIF、JUnit出力も欲しいときに、レポート経路を使います

次の一歩

フォルダを横断してこれらの検査を走らせること、どの所見がリリースを塞ぐかを決めること、結果を人が読むレポートへ変えることは、DelphiでのPDF/Aプレフライト検証の解説とバッチプレフライトレポートCLIで扱います。そもそもどのPDF/Aパートと適合レベルを狙うべきかは、PDF/Aアーカイブ適合のノートの主題です。その選択は、とりわけ、整数上限が適用されるかどうかを決めます

ここで示したバリデータは、PDFiumエンジンの上のレンダリング、フォーム記入、編集と並んで、Delphi、C++Builder、Lazarus向けのPDFium Componentに同梱されています。対応する適合標準、エディション、コンパイラバージョンは、PDFium Component製品ページに一覧されています