技術記事

Delphi で PDFium VCL を使う PDF/A プレフライト検証

アーカイブ取り込みゲートは、机上のどのビューアでも問題なく開く「PDF/A-2b」ファイルの一括投入を拒否した。供給元は準拠していると断言したが、そうではなかった。各ファイルには catalog の奥に埋め込まれた JavaScript action が入り、そうしたものは目視ではまず見つからず、veraPDF のような完全な PDF/A validator だけが一瞬でフラグを立てる。問題は、ファイルごとに yes-or-no を 1 つ返すためだけに Java の toolchain を Delphi の batch service に載せたくなかったことだ。それがValidatePdfACompliance PDFium Component が埋める空白であり、content stream を完全に解析せずに結論へどう至るのかを理解する価値がある

PDFium Component による Delphi 向け PDF/A プリフライトパイプライン。生 PDF からストリーム本体を剥ぎ、オブジェクトストリームを展開し、構造バイト上の Pascal トークン走査が 1 つの TPdfAValidationResult を満たす。その IsCompliant ゲートは検出されたレベルと空の問題集合を要求
ValidatePdfACompliance はコンテンツストリームを決して解析しません。ストリーム本体を空白にし、オブジェクトストリームを展開した上で構造バイトを走査し、合格には検出された適合レベルと空の問題集合が要ります

なぜ PDFium 自体ではこれに答えられないのか

まず正直に言うと、バンドルされている pdfium.dllpdfium.dll には PDF/A 機能がまったくない。ConvertToPDFA も、OutputIntent writer も、公開 surface に XMP API もない。ConvertToPDFAこのライブラリの PDF/A のあらゆる部分は、書き込み側も検査側も、純粋な Pascal の FPdfPdfa.pasFPdfPdfa.pas にあり、byte-level parsing と incremental update だけで動く。だから validator を呼んでも Chromium の renderer に何かを尋ねているわけではない。ファイルの structural bytes に対して Pascal の token scanner を走らせているだけだ

公開 API は意図的に小さい。1 つの関数が position 0 から stream を読み、record を返す:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // TPdfAValidationIssueのset
    function IsCompliant: Boolean;        // level <> unknown/noneのときだけTrue
  end;                                    // AND Issuesが空

IsCompliantIsCompliant は、gate で重要な規則をエンコードしている。real conformance level が検出され、issue set が空のときだけ file は pass になる。pdfaid marker が見つからないのに parse 自体は成功した場合は、pacNoneこれは明確に pass ではない。これは、一括 preflight report CLI が外から見たときに示しているのと同じ点だ: unrecognized file の findings list が空でも、clean bill of health ではない

ストリーム本体を取り除いてから、どの token scan も始める

ここが最重要の実装詳細で、自前の scanner を書くと最も間違えやすい点でもある。検出器は、区切られた name token を探して違反を見つける。たとえば「/JavaScript、/LZWDecode、/BM。生の file bytes を scan すると、埋め込まれた binary stream body、compressed images、ICC profiles、font programs が、たまたまそれらの token に見える byte sequence を含んでしまう。あなたは /AA または /3D「found」と報告してしまうだろう。なぜなら JPEG の中の 3 バイトがたまたまそう綴っていたからだ。これは false positive factory だ

修正は PdfStructureBytesPdfStructureBytes で、stream と endstream の keyword の間にある byte を spaces で塗りつぶし、dictionary structure はそのまま残す。そこまでやって初めて scan を実行する。validator の各 name-token check は、この stripped copy を対象に動く。この article から 1 つだけ持ち帰るなら、これだ。同じ discipline は PDF/UA validator にもある。そちらは独自の copy を持っており、2 つの standard が independent に進化するため、routine を別管理している

29 個の issue と、それぞれの意味

TPdfAValidationIssueTPdfAValidationIssue は documented contract だ。ordinal が固定されているのは、DUnitX tests、demos、report layer がすべてそれに依存しているからで、新しい finding は常に末尾に追加される。v1.63.0 時点で member は 29 個ある。いくつかの family に分かれる:

  • メタデータと識別情報: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • 色と出力: pvaiMissingOutputIntent, pvaiMissingIccProfile, および pvaiMixedDeviceColorSpaces(DeviceRGB と DeviceCMYK が両方現れるとき、6.2.3.3)
  • すべての part に対する厳格な禁止事項: pvaiEncryptionPresent(/Encrypt /Encrypt dictionary は完全に禁止されている), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • フォント: pvaiFontNotEmbedded と、より厳しい pvaiUnembeddedFont に加え、pvaiUnicodeMappingMissing を伴わない Level U の主張では /ToUnicode
  • タグ付け: pvaiLevelAStructureMissingconformance=A の主張に tagged structure がない場合

新しい 6 つの member は、ordinal 24 から 29 に追加され、レビューで本当に引っかかりやすい微妙なケースをカバーしている:pvaiTrappedTrue(/Trapped /True Info dictionary 内の "false friend" だ。値は False か Unknown でなければならないので、pvaiForbiddenActionSubtype(Sound や Movie を annotation ではなく action として使うもの), pvaiTransparentColorSpace(non-Normal blend mode、または /CA//ca が 1.0 ではないもの), pvaiAnnotationDictViolation, pvaiUnembeddedFont, and pvaiMixedDeviceColorSpaces

Delphi 向け PDFium Component PDF/A バリデーターの 29 の TPdfAValidationIssue コードの図。メタデータと識別、色と出力、ハード禁止、フォント、タグ付け、さらに pvaiTrappedTrue や pvaiTransparentColorSpace など最新 6 検出に分類
29 の問題コードは 5 つのファミリーと最新の 6 つに分かれます。暗号化、JavaScript、LZW のようなハードな禁止は、すべての PDF/A パートに適用されます

part を意識した gate: A-1 は厳格、A-2 と A-3 は緩和

PDF/A は 1 つの rulebook ではない。PDF/A-1 では禁止されている 3 つのものが、PDF/A-2 以降では明示的に許可されている: transparency(/Transparency group または active /SMask、6.4)、optional content(/OCProperties、6.1.13)、および embedded files(/EmbeddedFiles または /EF、6.1.11)。すべての file に対してこの 3 つを naive validator がまとめて弾くと、完全に valid な PDF/A-2 documents を一斉に拒否してしまう

それで validator は pdfaid marker から part number を PdfAPartOf で読み取り、これらの checks を PartNo = 1. 新しい transparency issues に対する blend-mode と annotation-alpha の checks も、同様に part 1 のみだ:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

1 つ保守的な default にも触れておくべきだ。pdfaid marker がまったくないとき、part は 1 とみなされる。最も strict だからだ。理由は、識別できない file は通過させるよりも最も tight な rules に従わせるべきだからだ。JavaScript、forbidden actions、LZW、XFA、NeedAppearances、forbidden annotations、unembedded fonts は、すべての part で forbidden のままなので、それらの checks は gate の外に置かれることはない

object stream を展開して、何も隠れないようにする

PDF 1.5 では cross-reference stream と object stream(/Type /ObjStm)が導入され、naive byte scanner には blind spot を生む。catalog、OutputIntent、action dictionary など、stream そのものではないものは何でも ObjStm の中で Flate-compressed にできる。raw structure を scan しても何も見えず、きれいに見える file が実際にはそうではないと報告してしまう

PdfExpandObjectStreamsPdfExpandObjectStreams がその隙間を埋める。どの check を走らせる前にも、validator は Data := PdfExpandObjectStreams(Data). その routine はすべての ObjStm を見つけ、その /N と /First header を読んで、含まれる object number と offset を取り出し、body を PdfInflate で inflate する(RTL の zlib、System.ZLib は Delphi、zstream は FPC)、そして各 contained object を通常の N 0 obj ... endobj として bytes の copy の末尾へ append する。既存の token checks は、logic を変えずにそれらの object を見つけられる

この設計が clean で fragile ではないのは 2 つの制約があるからだ。stream objects、Metadata、ICC profile、font programs は object stream に入れず、non-stream dictionaries だけが対象なので、展開は常に dictionaries だけを扱い、append された objects には body-stripping pass を乱す stream keyword が付かない。streamさらに、append された content が %%EOF の後ろに置かれるので、startxref からの reverse search でも original trailer をちゃんと見つけられる。cross-reference stream の trailer 自体は、すでに v1.49.3 で、plaintext の xref-stream dictionary から Root、Size、ID を直接読む形で処理済みだ。これは、姉妹記事の object と cross-reference streams の検証; object-stream の作業で必要だったのは inflate の step を足すことだけで、type-2 xref entries を decode したり PNG predictor を unwind したりする必要はなかった

Delphi におけるパート認識 PDF/A ゲート。宣言された pdfaid パートが PartNo = 1 ゲートに供給され、透過性、オプションコンテンツ、埋め込みファイルのチェックを PDF/A-1 限定で有効化。JavaScript、LZW、XFA、未埋め込みフォントは全パートで禁止のまま
パート対応のゲーティングは pdfaid パート番号を読み、透過、オプショナルコンテンツ、埋め込みファイルの各チェックを PDF/A-1 にだけ適用し、マーカーのないファイルは最も厳しい規則に置きます

byte-level checker の正直な限界

これは preflight tool であって certified validator ではなく、境界は実在する。font embedding は counting heuristic であり、正しくするには知っておく価値のある修正が必要だった。元の check は PdfCountName('/FontDescriptor') を使っていたが、各 font は 2 つの /FontDescriptor token を持つ。font dictionary からの reference と、descriptor object そのものの中の /Type だ。そのため count は埋め込み済み program 数 N に対して 2N になり、test は常に true だった。修正は PdfCountDescriptorRefs、これは /FontDescriptor N G R だけを数え、font ごとに 1 つの pvaiUnembeddedFont reference form をカウントし、embedded programs が本当に少ないときだけ

K := PdfCountDescriptorRefs(Struct);                 // font dictごとに1つ
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

それでも修正後であっても粗い。すべての descriptor がたまたま何らかの FontFile を持つ mixed document では、個々の non-conformant font を見逃すことがある。object streams を展開することには既知の副作用もある。AcroForm の /DR が持つ standard-14 default resources、たとえば /DR が持つ standard-14 default resources、たとえば /Helv を露出させるので、heuristic はそれらを not embedded と報告するが、veraPDF が通す理由は、それらが実際には描画に使われないからだ。Content-stream operator-level checks (6.2.10) は完全に scope 外で、byte scan ではなく full content parsing が必要になる。validator は violations marker injection では直せない違反を拾う fast かつ dependency-free の first gate とみなし、final certification には full validator を残しておくべきだ

これは story の checking half だ。対になる writing side では、SaveAsPdfAXMP、OutputIntent、sRGB ICC profile を inject し、tagged structure のない Level A request を正直に downgrade するが、それらも同じ byte-level machinery に基づいている。両方の half は PDFium Component for Delphi に同梱されており、外部 runtime を install せずに済む pure-Pascal の PDF/A implementation 上に乗る単一の VCL package だ