技術記事

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

タグ付きPDFでは、各ページの上部に繰り返し現れるヘッダーは通常 /Artifact <</Type /Pagination>> BDC … EMC の内側にあり、その下の最初の段落は /P <</MCID 0>> BDC … EMC の内側にあります。両者ともISO 32000-1 §14.6の意味でのマーク付きコンテンツですが、構造ツリーから到達できるのは段落のほうだけです。マーク付きコンテンツ識別子を運んでいるのが段落のほうだけだからで、描画されたグリフを /H1 や /P といった構造要素に結び付けるのは、この1つの整数です。したがってタグ付けの監査や修復を行うコードは、すべてのページオブジェクトからタグ、プロパティリスト、MCIDの3点を読み、その数が同じページの構造要素が実際に主張するMCIDと一致するかを確かめる必要があります

DelphiからBDCマークとプロパティリストを読む

PDFium Componentでは、TPdf.PageObjectMarks(Index)がIndex番目のページオブジェクトを囲むすべてのマークをTPdfContentMarkレコードの配列として返します。各レコードはNameにタグ名、プロパティリストを収めたParams配列、そしてHandleを持ちます。名前には先頭のスラッシュが付かないため、/PシーケンスはPと報告され、アーティファクトはArtifactと報告されます。マーク付きコンテンツのシーケンスはネストでき、/Sect、/P、/Spanと重なった内側にあるグリフ列は、同じオブジェクトに外側から順に3つのレコードとして報告されます

ISO 32000-1 §14.6.2では、BDCのプロパティリストオペランドに2つの形式を認めています。/P <</MCID 3>> BDCのようなインライン辞書と、ページリソースの/Propertiesサブ辞書を指す/OC /oc1 BDCのような名前です。オプショナルコンテンツ(§8.11.3.2)は後者の形式で書かれます。PDFiumはページの解析時に名前形式を解決するため、どちらの場合もParamsにはそのシーケンスに適用される辞書のエントリが列挙されます

TPdf.PageObjectMarkedContentID(Index)は、構造ツリーが関心を持つ問いに答えます。オブジェクトのマークから見つかったMCIDを返し、どれも整数の/MCIDを持たなければ-1を返します。冒頭例のヘッダーが-1を返すのは正しい挙動です。§14.8.2.2はアーティファクトを論理構造の外に置くため、アーティファクトには結び付ける先がないのです

Delphi における PDF マーク済みコンテンツの解剖図。BDC と EMC の間の MCID プロパティリスト付き /P タグ、TPdfContentMark レコードフィールド、構造ツリーへの MCID リンク
マークは BDC と EMC の間にあるタグと型付きプロパティリストであり、MCID が構造ツリーに加わるレコードとして報告されます
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber は1始まり
  for I := 0 to Pdf.ObjectCount - 1 do    // ページオブジェクトのインデックスは0始まり
  begin
    Marks := Pdf.PageObjectMarks(I);
    if Length(Marks) = 0 then
      Continue;
    Memo1.Lines.Add(Format('object %d, MCID %d',
      [I, Pdf.PageObjectMarkedContentID(I)]));
    for M in Marks do                     // 外側のマークが先頭
    begin
      Memo1.Lines.Add('  mark ' + M.Name);
      for P in M.Params do
        case P.Kind of
          pmpInt:     Memo1.Lines.Add('    ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpFloat:   Memo1.Lines.Add('    ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpString:  Memo1.Lines.Add('    ' + P.Key + ' = ' + P.StringValue);
          pmpBlob:    Memo1.Lines.Add('    ' + P.Key + ' = ' +
                        IntToStr(Length(P.BlobValue)) + ' bytes');
          pmpUnknown: Memo1.Lines.Add('    ' + P.Key + ' = (no readable value)');
        end;
    end;
  end;
end;

pmpUnknownに分かることと分からないこと

各TPdfContentMarkParamはKeyとKind、そしてその種類が選ぶ1つの値フィールドを持ちます。対応関係は暗記できるほど短いものです

  • 整数値の数値はpmpIntになり、IntValueが設定され、FloatValueにも同じ数値が入ります。/MCID 3も迷入した3.0も整数として読めます
  • 小数部を持つ数値はpmpFloatになります
  • PDF文字列もPDF名もpmpStringになります。UTF-16のバイトオーダーマーク(FE FFかFF FE)で始まるテキスト文字列はデコードされてStringValueに入るため、/ActualTextは読めるテキストとして届きます
  • その他の値型は、PDFiumがバイト列を渡してくれる場合はpmpBlobで返り、そうでなければpmpUnknownのままです

pmpUnknownには区別がつかない2つのケースが含まれます。1つはPDFiumがFPDF_OBJECT_UNKNOWNとして報告しバイト列を渡してこない値です。もう1つはリストには現れるのに読み戻せないキーです。壊れたファイルで非ASCIIバイトで綴られたキーはリストに現れながら、やはりpmpUnknownで返ることがあります。どちらであってもレコードの見た目は同じです

pmpUnknownは「使える値がない」ものとして扱い、受け入れるのではなくマークごと報告します。ワークフローが/Langや/ActualTextの存在に依存しているなら、自分が認識できる種類でキーを探してください。未知の種類だからといって、プロパティが存在しないと結論してはいけません

Handleは現在読み込まれているページ内のマークを識別するもので、マークの削除、ページオブジェクトの破棄、ページのアンロードが起きると無効になります。ページを切り替える前にNameとParamsを自分の構造体へコピーしてください。リストビューのような表示やバックグラウンド監査のように現在のページより長く生きるデータには、ReadPageObjectsSnapshotが名前とパラメータだけを持ちハンドルを一切含まないTPdfContentMarkSnapshotレコードを返します。同じ規則はページオブジェクトハンドル全般に当てはまり、変換後に無効になるページオブジェクトハンドルの解説でも触れています

マークの追加、更新、削除

編集は4つの呼び出しで賄います。AddPageObjectMark(Index, Name, Params)はオブジェクトに新しいマークを作り、パラメータを順に適用し、そのオブジェクトにおける新しいマークのインデックスを返します。残りの3呼び出しはこれをMarkIndexとして受け取ります。名前はスラッシュなしで渡します。'P'、'Span'という具合です。1つのキーを変更するにはSetPageObjectMarkParam(Index, MarkIndex, Param)を使います。挿入または置換が行われ、マーク上の他のパラメータはそのまま残ります。削除系はもっと直接的です。RemovePageObjectMarkParam(Index, MarkIndex, Key)がキーを1つ落とし、RemovePageObjectMark(Index, MarkIndex)がマーク全体を落とします

AddPageObjectMarkはマークとそのパラメータを1ステップとして扱います。パラメータが1つでも拒否されると(pmpUnknown種別、空のキー、空のblob)、例外が伝播する前に作成済みのマークが取り除かれるため、オブジェクトがプロパティリスト半分のタグを抱えたままになることはありません

これらの呼び出しはどれも、戻る前にページコンテンツストリームを再構築します。これが重要なのは、SaveAsがコンテンツストリームをその時点のまま書き出し、PDFiumのオブジェクトモデルから再生成しないからです。メモリ上のオブジェクトにしか存在しなかったマークは、保存ファイルに1つもBDCオペレータを残しません。ObjectHandleに生のPDFiumインポート経由でページオブジェクトを編集する場合は、保存前にUpdatePageを自分で呼び出してください

Delphi でマーク済みコンテンツタグを追加する図。AddPageObjectMark は SaveAs の前にページコンテンツストリームを再構築する一方、オブジェクトモデルのみの変更は保存ファイルから無音で落ちる
AddPageObjectMark がコンテンツストリームを組み立て直すため、SaveAs がタグを永続化します。オブジェクトモデルで止まった変更は、ファイルに決して届きません

呼び出しごとにストリーム全体を再構築するのは、数件の編集なら問題なく、数百件では無駄になります。タグのないレポートをページごとにタグ付けするのは後者の類の作業で、その場合の編集はBeginPageObjectEditTransactionとCommitPageObjectEditTransactionの内側に置くべきです。トランザクション中の編集はページを変更済みとして印を付けるだけで、コミット時にコンテンツストリームが1度だけ生成され、RollbackPageObjectEditTransactionはページを元の状態へ戻します。トランザクションは1ページに束縛され、開いている間にPageNumberを別のページへ設定すると例外が上がります。署名済みドキュメントでは、AllowSignedDocument = Trueを渡さない限り開始呼び出しが拒否されます。ページコンテンツの編集はいずれにせよ署名を壊すからです

文字列パラメータにはもう1つ決めることがあります。pmpStringの値はバイト文字列として書き込まれ、/Lang (en-US)のようなASCII値にはまさに適切です。アクセント付き文字、合字、CJK文字を含む置換テキストは、FE FF接頭辞付きのUTF-16BEでエンコードしたPDFテキスト文字列(§7.9.2.2)に入れます。そのバイト列はpmpBlobとして渡します。PDFiumは文字列オブジェクトとして格納し、上のリーダーがpmpStringとしてStringValueへデコードして戻します

// ライブラリの一部ではない自作ヘルパー。BOM付きUTF-16BEのPDFテキスト文字列
function Utf16TextString(const S: string): TBytes;
var
  I: Integer;
begin
  SetLength(Result, 2 + Length(S) * 2);
  Result[0] := $FE;
  Result[1] := $FF;
  for I := 1 to Length(S) do
  begin
    Result[I * 2] := Byte(Ord(S[I]) shr 8);
    Result[I * 2 + 1] := Byte(Ord(S[I]));
  end;
end;

procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
  Params: TPdfContentMarkParams;
  Lang: TPdfContentMarkParam;
  MarkIndex: Integer;
begin
  Pdf.BeginPageObjectEditTransaction;
  try
    SetLength(Params, 2);
    Params[0].Key := 'MCID';
    Params[0].Kind := pmpInt;
    Params[0].IntValue := Mcid;
    Params[1].Key := 'ActualText';
    Params[1].Kind := pmpBlob;            // テキスト文字列として格納され、読み戻すと pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // ASCII値なので素のバイト文字列で問題ない
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // バッチ全体でコンテンツストリームの再構築は1回
end;

コミットがtryブロックの外に置かれているのは意図的です。コンテンツ生成に失敗すると、CommitPageObjectEditTransactionは再スローの前にページ自体を復元し、2度目のロールバック呼び出しは「No page-object edit transaction is active」という本来のエラーを覆い隠す例外を上げるだけになります

なぜ1つの段落にMCID 5のシーケンスが3つできるのか

各AddPageObjectMarkの呼び出しは、値が同一でも新しい独立したマーク辞書を作るからです。1行1オブジェクトの3つのテキストオブジェクトとして組まれた段落を、それぞれ/MCID 5を渡す3回の呼び出しでタグ付けすると、独立したマークが3つ付きます。PDFiumがコンテンツストリームを再生成するとき、連続するオブジェクトを1つのBDC/EMCペアにまとめるのは、同じマークを共有している場合だけです。比較するのはマークの同一性であって、値の一致は数えません。その結果、保存されたページには/P <</MCID 5>> BDC … EMCが3回連続で並びます

ISO 32000-1 §14.7.4.2は、MCIDを「そのコンテンツストリーム内でマーク付きコンテンツシーケンスを一意に識別するもの」と定めています。1つの識別子を共有する3つのシーケンスはこれを満たさず、ページの親ツリーエントリ経由でMCIDを解決するツールは、3つのうちどれを構造要素が意味するのか推測するしかありません

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex)は、コピーを新しく作るのではなく、ソースマーク自身のプロパティ辞書をターゲットオブジェクトに付けます。最初の行にだけマークを追加し、残りの行へ共有し、オブジェクトを描画順で連続させておけば、再生成されたストリームは3行すべてを1つのシーケンスで包みます

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: 段落の各行のオブジェクトインデックス。描画順で連続
var
  Params: TPdfContentMarkParams;
  MarkIndex, I: Integer;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := Mcid;

  Pdf.BeginPageObjectEditTransaction;
  try
    MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
    for I := 1 to High(LineObjects) do
      Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;
end;

共有には、既存のマークを新しいコンテンツへコピーするとき、例えば描き直したフッター罫線にアーティファクトを広げるときにもう1つの利点があります。PDFiumが書き込めるパラメータの種類は整数、浮動小数点、文字列、バイトblobで、名前の種類はありません。/Artifact <</Type /Pagination>>をpmpStringとして読んでAddPageObjectMarkで組み立て直すと、§14.8.2.2が名前/Paginationを期待するところに文字列(Pagination)が出来上がります。共有されたマークは元の辞書そのものなので、名前は名前のまま保たれます。ソースのマークがページに既に存在するなら常に共有し、Paramsからの再構築は、まだ存在しないマークを書くときだけにしてください

MCIDを構造ツリーと突き合わせる

ページオブジェクト上のMCIDは、同じページの構造要素がそれを主張して初めて意味を持ちます。StructureElementsForPage[PageNumber]はページの要素をTPdfStructureElementレコードとして返し、MCIDを運ぶフィールドは3つあります。要素が持たない場合に-1になるMarkedContentID、複数のシーケンスを所有する要素向けのMarkedContentIDs、そして子がマーク付きコンテンツではなく要素であるスロットに-1が入るChildMarkedContentIDsです。構造要素の/Kエントリは1つの配列に子要素とマーク付きコンテンツ参照を混在させられるため、監査では3つすべてを見る必要があります

下の監査コードは2つの所見を報告します。MCIDを主張する要素がないテキストや画像、そもそもMCIDを持たないテキストや画像は、支援技術が読み飛ばすコンテンツです。MCIDを主張されているArtifactは§14.8.2.2に真っ向から反します。ページオブジェクトはReadPageObjectsSnapshotから取得するので、結果はハンドルを保持せず、ページ切り替え後も使えます

// ライブラリの一部ではない自作ヘルパー
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
  E: TPdfStructureElement;
  Id: Integer;
begin
  for E in Elements do
  begin
    if E.MarkedContentID = Mcid then
      Exit(True);
    for Id in E.MarkedContentIDs do
      if Id = Mcid then
        Exit(True);
    for Id in E.ChildMarkedContentIDs do
      if Id = Mcid then
        Exit(True);
  end;
  Result := False;
end;

procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
  Snap: TPdfPageObjectsSnapshot;
  Status: TPdfPageObjectSnapshotStatus;
  Elements: TPdfStructureElements;
  Obj: TPdfPageObjectSnapshot;
  IsArtifact: Boolean;
  J: Integer;
begin
  Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
    TPdfPageObjectSnapshotOptions.Full, Status);
  if not Status.Completed then
    Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
  Elements := Pdf.StructureElementsForPage[PageNo];
  for Obj in Snap.Objects do
  begin
    IsArtifact := False;
    for J := 0 to High(Obj.Marks) do
      if Obj.Marks[J].Name = 'Artifact' then
        IsArtifact := True;
    if IsArtifact then
    begin
      if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
    end
    else if Obj.ObjectType in [otText, otImage] then
      if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
  end;
end;

罫線を意図的に対象外にしています。装飾的な罫線や表の枠線はタグ付けされないことが多く、すべてを検出するとテキストに関する所見が埋もれます。Form XObjectには逆の慎重さが必要です。フォーム内のMCIDはそのフォーム自身の/StructParents名前空間に属し、ページ上のMCID 0とフォーム内のMCID 0は別のシーケンスです。タグ付き Form XObject の MCID 名前空間の解説で、正しい読み方を扱っています

マークはそれでもタグ付きドキュメントの半分に過ぎません。ISO 32000-1 §14.7は、これらのMCIDを参照する要素を持つ構造ツリー、カタログの/Marked trueを伴う/MarkInfo辞書、そして標準の構造型かロールマップで標準型に対応付けられた構造型を求めます。カタログがドキュメントをタグ付きと宣言しているかどうかはPdf.IsTaggedで分かります。どの要素も指さないMCIDを持つ/Pマークは、構造ツリーが一度も言及しないページでタグ付きと名乗るコンテンツを生み出します

BDC マークだけではタグ付き PDF にならないことを示す PDFium Component 図。MCID を参照する構造ツリー、MarkInfo 宣言、標準ロール名も必要
マークは監査と孤児修復に仕えますが、準拠したタグ付きドキュメントにはさらに、構造ツリー、MarkInfo、標準ロール名が要ります

その作業の構造ツリー側は、PDF/UA構造ツリー検証の解説がMCID監査の続きを引き取ります。タグが存在する目的である読書体験については、DelphiでアクセシブルなPDFリーダーを作る解説がテーマです

ここで使った呼び出し(マーク読み取り、編集トランザクション、マーク共有、ページオブジェクトスナップショット、構造要素)は、PDFium ComponentがPDFiumエンジンの上でDelphi、C++Builder、Lazarusの各プロジェクトに提供している同一のVCL APIの一部です。アクセシビリティとページ編集APIの残りについては、PDFium Component製品ページがカバーしています