技術記事

DelphiでPDFのマーク付きコンテンツを読み書きする方法

マーク付きコンテンツは、ISO 32000-1 §14.6がページコンテンツのタグ付けのために定義するメカニズムであり、タグ付きPDFとPDF/UAはどちらもその上に構築される。PDFium Componentはこれを直接公開する。PageObjectMarksはページオブジェクトからすべてのBDCタグとそのプロパティリストを読み、AddPageObjectMarkは1つを書き、RemovePageObjectMarkは1つを削除し、PageObjectMarkedContentIDはコンテンツを構造ツリーにつなぐMCIDを返す

構造ツリーが、それが記述するコンテンツにつなぎ戻せるようになるまで、アクセシビリティツールは推測でしかない。構造ツリーは「これは見出し」と言う。MCIDは、その見出しが実際にどのページのどのマークであるかを言う。アプリケーションがタグ付けをチェックし、修復し、報告するには、両方が読めなければならない

バイト列におけるマークとは何か

タグ名とオプションのプロパティリストを持つBDCオペレータで、EMCで閉じられる。コンテンツストリームの中では/P <</MCID 3>> BDC ... EMCのように見える。タグ/Pがロールを名指し、辞書がプロパティを運び、オペレータの間にあるすべてがマーク付きコンテンツだ。そのスパンの中のページオブジェクトがマークを持ち、それがPDFiumが返すものであり、PDFium Componentがレコードに変えるものだ

TPdfContentMarkはハンドル、タグNameTPdfContentMarkParamの配列を保持する。各パラメータはKeyKind、その種別によって選ばれる1つの意味のある値フィールドを持つ。pmpIntpmpFloatpmpStringpmpBlobだ。種別は、たまたま成功したゲッターからではなく、PDFium自身の型報告から来る。これが、プロパティリストを読むことと、プロパティリストを推測することの違いだ

var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber is 1-based
  for I := 0 to Pdf.ObjectCount - 1 do    // page object indexes are 0-based
  begin
    Marks := Pdf.PageObjectMarks(I);
    for M in Marks do
    begin
      Memo1.Lines.Add('mark ' + M.Name +
        ' (MCID ' + IntToStr(Pdf.PageObjectMarkedContentID(I)) + ')');
      for P in M.Params do
        case P.Kind of
          pmpInt:    Memo1.Lines.Add('  ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpString: Memo1.Lines.Add('  ' + P.Key + ' = ' + P.StringValue);
          pmpFloat:  Memo1.Lines.Add('  ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpBlob:   Memo1.Lines.Add('  ' + P.Key + ' = ' +
                       IntToStr(Length(P.BlobValue)) + ' bytes');
        end;
    end;
  end;
end;

なぜpmpUnknownが2つの異なることを意味するのか

pmpUnknownは、PDFiumがFPDF_OBJECT_UNKNOWNを報告したときに返される。そしてPDFiumは、存在しないキーに対しても同じものを返す。この層では2つのケースを区別できず、そうでないふりをするのは、そう述べるよりも悪い

あなたのコードに対する実地の帰結はこうだ。pmpUnknownを「ともかくデコードを試みるかもしれない型」としてではなく、「ここに使える値はない」として扱うこと。プロパティがワークフローにとって重要なら、認識できる種別で存在することを検証し、未知から不在を推測しないこと。プロパティリストを読めないマークは、黙って受け入れるべきマークではなく、報告すべきマークだ

マークレコードはあなたが所有するハンドルではなくスナップショットだ

Handleフィールドはライブラリに属する。マークが削除され、ページオブジェクトが破壊され、ページがアンロードされた瞬間に古くなる。だからレコードは短い命の読み取り専用スナップショットだ。ページをまたいでキャッシュすれば、エンジンが回収したメモリを指すポインタを握ることになる

これはPDFiumにおけるページオブジェクトハンドル全般に適用される規律であり、同じ場所で人を捕らえる。マークレコードで生成されたリストコントロール、ユーザーが別のページへ移動し、ナビゲーションと無関係に見えるクラッシュ。必要な値——名前、キー、数値——を書き出し、ハンドルは手放すこと。変換後に古くなるページオブジェクトハンドルのノートが、一般則とそれがほかのどこで噛むかを扱っている

マークの追加と、見逃しやすい保存ステップ

AddPageObjectMarkはページオブジェクトのインデックス、タグ名、完全なパラメータセットを受け取る。パラメータは1回でセットとして書かれ、キーを1つずつパッチするわけではない。だからTPdfContentMarkParamHas*センチネルがない。それらが守るはずの「既存レコードの1フィールドを更新する」というケースは生じない

明示的に述べる価値のある部分がある。マークを追加するとページコンテンツストリームをリビルドし、タグが保存を生き残るようにする。SaveAsは単独ではコンテンツを再生成しないため、これを明示的にしなければならなかった。オブジェクトモデルの中にだけ存在する変更は捨てられ、保存されたファイルは元のものとまったく同じに見える。PDFiumのページに何かを追加し、出力にないことに気づいたことがあるなら、通常はこれが理由だ

var
  Params: TPdfContentMarkParams;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := NextMcid;
  Pdf.AddPageObjectMark(ObjectIndex, 'P', Params);   // rebuilds the content stream
  Pdf.UpdatePage;
  Pdf.SaveAs('tagged-out.pdf');
end;

これがドキュメントを何にし、何にしないか

マーク単独ではタグ付きPDFを作らない。適合するタグ付きドキュメントは、これらのMCIDを参照する構造要素を持つ構造ツリーと、ドキュメントがマーク済みであると宣言する/MarkInfoエントリと、標準の言う通りの意味を持つロール名を必要とする。どの構造要素も指さないMCIDを持つ/Pマークを書けば、タグ付きだと主張するコンテンツと、それに一度も言及しない構造ツリーを得る

このレベルでマーク付きコンテンツが真に価値を発揮するのは、点検と修復だ。どのページオブジェクトがタグ付きかを監査し、アーティファクトとしてマークされるべきだったものを見つけ出し、構造ツリーに対してMCIDを突き合わせて孤立を見つける。その作業の構造ツリー側については、PDF/UA構造ツリー検証の解説を参照のこと。そしてタグが最終的に向けられる読書体験については、DelphiでアクセシブルなPDFリーダーを作るノートを参照のこと

PDFium ComponentはDelphi、C++Builder、Lazarusのアプリケーションに、PDFiumエンジン上の高レベルVCL APIを与え、マーク付きコンテンツ、構造ツリー、アクセシビリティ検証を通常のPascalコードから扱えるようにする。完全なAPI面についてはPDFium Component製品ページを参照のこと