技術記事

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 自体ではこれに答えられないのか

まず正直に言うと、バンドルされている 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;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

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 で、streamendstream の keyword の間にある byte を spaces で塗りつぶし、dictionary structure はそのまま残す。そこまでやって初めて scan を実行する。validator の各 name-token check は、この stripped copy を対象に動く。この article から 1 つだけ持ち帰るなら、これだ

同じ discipline は PDF/UA validator にもある。そちらは独自の copy を持っており、2 つの standard が independent に進化するため、routine を別管理している

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

  • TPdfAValidationIssue は documented contract だ。ordinal が固定されているのは、DUnitX tests、demos、report layer がすべてそれに依存しているからで、新しい finding は常に末尾に追加される。v1.63.0 時点で member は 29 個ある。いくつかの family に分かれる:メタデータと識別情報pvaiMissingXmpMetadata: pvaiMissingPdfAIdentifier, pvaiMissingTrailerId, pvaiMissingXmpDates (ISO 19005-1 6.1.3),
  • .色と出力pvaiMissingOutputIntent: pvaiMissingIccProfile, and when both DeviceRGB and DeviceCMYK appear (6.2.3.3).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

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 したりする必要はなかった

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);                 // one per font dict
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 だ