技術記事

DelphiとPDFiumでの注釈外観のラウンドトリップ

v3.121.1より前のPDFium Componentでは、TPdf.Annotation[]で注釈を読んでレコードを代入し戻すと、元が/Nしか運んでいなくても、/AP外観辞書に空の/Rと/Dエントリが追加されることがありました。PDF/Aバリデーターはその辞書を拒否します。v3.121.1以降、getterは実際に読めた外観だけを報告するので、無変更のラウンドトリップは何も新しく書きません。この障害は細部まで理解する価値があります。いつもの引き金は、ファイルをより適合させるためだった、より適合させないためではなかった修正だからです

PDFium Componentの注釈ラウンドトリップの図。TPdf.Annotation[]とSetAnnotationDataでafPrintを足すと、FPDFAnnot_SetAP経由で空の/Rと/Dストリームも書かれ、PDF/Aで綺麗だった外観辞書がveraPDFに拒否される辞書へ変わります。v3.121.1が実際に読めた外観だけを報告するようになるまで
注釈を読んで無変更のまま書き戻すだけで、空のロールオーバーとダウンの外観ストリームが追加されていました。PDF/Aに落ちるのはそのせいであって、足そうとしたPrintフラグのせいではありません

注釈を無変更で書き戻すと何が起こるのか

短く言えば、注釈は一度も持たなかった外観ストリームを手に入れ、編集前はPDF/A検証を通っていたファイルが、編集後に落ちます。典型的なシナリオはこう走ります。顧客アーカイブにPrintフラグのない正方形とテキストの注釈が届く。PDF/Aはすべての注釈に印刷を要求するので、ページをループし、afPrintを足し、各レコードを代入し戻す。このコードは外観には一切触れません。TPdf.Annotation[]からのレコードはTPdfAnnotationで、SetAnnotationDataはHas*センチネルの立ったフィールドをすべて書きます。HasContents / ContentsTextのペアがそう動くはずになっているのと同じです。問題は、getterが存在しないモードについてHasAppearanceRolloverとHasAppearanceDownを空文字列付きのTrueに設定し、setterが律義に2つの空ストリームを書いていたことです

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for PageNo := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := PageNo;
      for I := 0 to Pdf.AnnotationCount - 1 do
      begin
        A := Pdf.Annotation[I];
        if not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // v3.121.1より前は、この代入でソース注釈が/AP/Nだけしか
          // 持たないとき、空の/AP/Rと/AP/Dストリームも書かれていました
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5は外観辞書を3つのエントリで定義します。normal外観の/N、ロールオーバーの/R、ダウンの/Dです。/Rと/Dはオプションで、欠けていればビューアは/Nへフォールバックします。しかし空の/Rストリームは欠落ではありません。何も描かない正当なストリームであり、ロールオーバー外観を尊重するビューアは、ポインタが注釈の上を通った瞬間に空白の矩形を見せます。PDF/Aはさらに厳しく、ISO 19005-1(Corrigendum 2付き)もISO 19005-2 / 19005-3も、注釈外観辞書には/Nしか許しません。veraPDFはラウンドトリップ済みファイルをPDF/A-1のrule 6.5.3-4、PDF/A-2とPDF/A-3のrule 6.3.3-2で報告し、組み込みのTPdf.ValidatePdfAはpvaiAnnotationApDictViolationとして列挙します。規格の1条項を満たすためにPrintフラグを足した編集が、別の条項を破ったのです

ISO 32000-1の注釈外観辞書のnormal、rollover、downエントリの図。PDFiumは欠落ストリームにも既存の空ストリームにも2バイトを返すので、どちらもTPdf経由では内容なしとして読み戻ります。PDF/Aが禁じる空ストリームを見つけられるのは、TPdf.ValidatePdfAのようなバイトレベルの検査だけです
欠落した/Rは/Nへフォールバックします。空の/Rは空白の矩形を描き、PDF/Aにも落ちます。レコード経由ではこの2つは区別できません

FPDFAnnot_GetAPが存在しない外観に2を返す理由

PDFiumはFPDFAnnot_GetAPからゼロを返しません。要求された外観ストリームが存在しないときでもです。この関数はPDFium流の2回呼びパターンに従います。nilバッファを渡して必要なバイト数を受け取り、確保してから、もう一度呼んでUTF-16LEテキストをコピーします。サイズには常にUTF-16ターミネータが含まれるので、存在しないストリームは2バイト、空文字列+ターミネータを報告します。v3.121.1より前のgetterはByteLength >= SizeOf(FPDF_WCHAR)をテストしていました。どんな呼びでも通るチェックです。だから外観を1つでも持つ注釈なら、3つのHasAppearance*フラグはすべてTrueで戻りました。レコード経由のラウンドトリップは各モードに空文字列の保存をFPDFAnnot_SetAPへ依頼し、PDFiumはそれを保持するストリームを作りました。例外も警告もなく、見た目のページは同一です。だからこの欠陥はビューアではなくveraPDFのフィクスチャで顔を出したのです

FPDFAnnot_GetAPがPDFiumで欠落した外観ストリームを報告する仕組みの図。2回呼びパターンはUTF-16ターミネータのために常に2バイト以上を返し、SizeOf(FPDF_WCHAR)との比較だった旧ゲートはどんな呼びも通して全HasAppearanceセンチネルをTrueにしました。v3.121.1のゲートはターミネータより多く、かつ偶数バイトを要求します
2バイトは符号化された空文字列であって、外観の存在の証明ではありません。修正済みgetterはターミネータ長以下を内容なしとして扱い、書き戻しは無音のままです

v3.121.1が外観の存在を判定する方法

GetPageAnnotationの内部ヘルパーReadAppearanceは、AppearanceNormal、AppearanceRollover、AppearanceDownを埋めますが、いまや結果を内容として扱うのは、ターミネータの先に少なくとも1文字運ぶときだけです。最初の呼びはSizeOf(FPDF_WCHAR)を超えるバイト数と偶数のバイト数を返さなければなりません。奇数の長さはUTF-16になり得ないからです。実際にテキストをコピーする2回目の呼びも再検証されます。返された長さが2以下、あるいは確保したバッファより大きい場合、HasValueはFalseへ戻り、文字列は空のままです。書き込み側は無変更です。SetAnnotationDataは依然として、HasAppearance*フラグがTrueのモードについてだけFPDFAnnot_SetAPを呼びます。/Nしか持たない注釈から読んだレコードは、いまや/Nだけを書き戻します。リグレッションフィクスチャは両方向をカバーします。normal外観を持つ正方形注釈を読んで無変更で書き戻すとPDF/A-1b、PDF/A-2b、PDF/A-3bを通過し、同じ注釈からPrintフラグを取り除くと、期待どおりのフラグ規則だけで落ちます

欠落と空のストリームは見分けがつかない、だからgetterは保守的なまま

ネイティブAPIには、存在するが空の外観ストリームと、欠落している外観ストリームの区別がつきません。PDFium Componentもそれを偽りません。どちらのケースもFPDFAnnot_GetAPから同じ2バイトが返るので、どちらもHasAppearanceRollover = False、空のAppearanceRolloverとして読み戻ります。ここから設計すべき帰結が2つ出ます。第1に、Falseのセンチネルは「内容を読めなかったので、書き戻しはこのモードに触れない」を意味します。/Rキーが辞書に欠けていることを意味するのではありません。第2に、レコードはファイルにすでにある空ストリームを検出できません。古いビルドや別のツールに傷つけられた文書は綺麗に読み戻り、レコードを代入し戻しても直りも悪化もしません。そういうファイルを見つけるにはバイトレベルの検査が要ります。TPdf.ValidatePdfAとPDFium ComponentによるPDF/Aプリフライト検証ワークフローがそのためのものです

外観を意図的にクリアするには

センチネルを明示的に立てて空文字列を渡します。setterはそれを書きます。SetAnnotationDataで空文字列を塞ぐのがこのバグへの安直な修正でしたが、それでは外観を意図的にクリアする呼び出し元まで壊れます。テキストについてHasContentsとHasAuthorが守っているのと同じ契約です。だから修正はすべてgetter側に住み、setterは呼び出し元が求めたものを引き続き尊重します

// ロールオーバー外観を置き換えてから、再度クリアする
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRolloverはTrueで、テキストは'q Q'として往復する
A.HasAppearanceRollover := True;   // 意図を明示的に言い直す
A.AppearanceRollover := '';        // 意図して空ストリームを書く
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// 読み戻すとHasAppearanceRollover = Falseで空文字列:
// ここでは空ストリームと欠落ストリームは区別できない

明示的に空にした/Rや/Dも、前述のPDF/A規則では引き続き余分なキーとして数えられる点に注意してください。目標がアーカイブプロファイルなら、空でない/Nを書き、他の2モードに触れない形だけが検証を通ります。注釈を文書間で動かすワークフロー、たとえばPDFium ComponentでのXFDFエクスポートとインポートも、同じ規則に従うべきです。ソースが実際に持っていたモードをコピーし、残りのセンチネルはFalseのままにする

PDF/A安全を保つ読み取り・変更・書き込みパターン

v3.121.1以降へアップグレードし、外観センチネルはgetterが返したとおりのままにして、出荷前に保存済みファイルを検証します。古い空ストリームは欠落として読み戻るため、検証ステップはレコードではなくシリアライズ後の文書を見なければなりません。バッチごとに回すにも安い費用です

uses
  PDFium, FPdfPdfa;  // FPdfPdfaはTPdfAValidationIssueを宣言する

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Pdfに現在ロードされている文書を検証する。開いた後に行われた
  // Pdf.Annotation[]経由の編集も含めて
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

同じ規律は、レビュー用にページを再着色したり注釈を付けたりするどんなパネルにも当てはまります。PDFium ComponentでDelphiの注釈レビューワークフローを組むで扱うワークフローです。レコードはエンジンが読めたもののスナップショットであり、自分で立てていないセンチネルは無変更のまま戻るべきです。注釈API一式、PDF/Aプリフライト、ネイティブのPDFiumエンジンは、Delphi、C++Builder、Lazarus向けPDFium Componentにまとめて搭載されています