技術記事

Delphi で /ParentTree と /StructParents を同期させる

PDFium Component は、FPdfDocumentStructure.pas の EditPdfDocumentStructure を通じて、Delphi から既存の PDF コンテンツへタグを付けます。psekAddMarkedContent と psekAddObjectReference の操作は、構造要素の /K に MCR や OBJR を追記する以上のことをします。/StructParents か /StructParent のキーを割り当て、ISO 32000-1 §14.7.4.4 が要求する逆インデックスである対応する /ParentTree エントリも書くのです

タグの後付けがたいてい間違えるのは、その後半です。BDC の包みと MCR を足すのは簡単で、上から構造ツリーを辿るビューアは新しいコンテンツを喜んで見つけます。ところが支援技術はしばしば逆方向から来ます。ページ上の 1 つのコンテンツから出発し、どの構造要素がそれを所有するかを尋ねます。ページに /StructParents キーがなければ、あるいは /ParentTree に新しい MCID のエントリがなければ、その探索は失敗し、検査器はそれを欠けたか不整合な親ツリーとして報告します。ファイルはタグ付きに見えるのに、依然として動きません

/ParentTree は実際に何を写像するのか

/ParentTree は整数キーを構造要素へ写像するナンバーツリーで、キーはツリーからではなくコンテンツから来ます。ISO 32000-1 §14.7.4.4 は、マーク済みコンテンツを含むすべてのページまたは Form XObject に /StructParents の整数を与え、そのキーの下の親ツリー値は MCID でインデックスされた配列です。配列のエントリ 3 は <</MCID 3>> を所有する構造要素です。コンテンツアイテム丸ごととして使われる注釈や XObject は、代わりに単一の /StructParent キーを受け取り、その親ツリー値は配列ではなく構造要素そのものです

Delphi で PDFium Component が書く PDF/UA の親ツリー逆インデックス。MCID 3 を運ぶコンテンツマークがページの /StructParents キー 17 を通じて /ParentTree のナンバーツリーへ解決され、その配列エントリ 3 がマーク済みコンテンツを所有する構造要素になる
タグ付き PDF では読み順が重要です。支援技術はページ上の MCID から /StructParents を通じて /ParentTree 配列へ歩き、構造ツリー自体は正常に見えても、欠けたキーやエントリがその探索を壊します

コンテナ形式は §7.9.7 の汎用ナンバーツリーです。リーフは /Nums、キーと値のペアをキー昇順に並べた平坦な配列を保持します。ルートの下の中間ノードとリーフは、カバーする最小と最大のキーを持つ /Limits を運び、ルート自体には /Limits があってはなりません。その上、構造ツリールートは /ParentTreeNextKey(§14.7.2)、つまりライターが次に配るべきキーを運び得ます。したがって、新しいコンテンツへタグを付けるすべての編集は、一致しなければならない最大 4 つのオブジェクトに触れます。コンテンツストリーム、ページかフォームの辞書、親ツリーのリーフ、構造ツリールートです

順方向には §14.7.4.2 と §14.7.4.3 が定める独自の規則があります。ページ自身のコンテンツストリーム内のコンテンツに対するマーク済みコンテンツ参照(MCR)は、/Pg と /MCID だけで足ります。マーク済みコンテンツが Form XObject の中に住むとき、MCID はコンテンツストリームごとに番号が振られるため、MCR はそのストリームを名指しする /Stm も要ります。オブジェクト参照(OBJR)は /Obj で注釈か XObject を、/Pg でそのページを名指しします

EditPdfDocumentStructure でコンテンツストリームへタグを付ける

EditPdfDocumentStructure はスナップショットガード付きのバッチエディタです。まず ReadPdfDocumentStructure を呼んで TPdfDocumentStructureSnapshot を受け取り、その SourceSha256 を TPdfStructureEditOperation レコードの配列と一緒に渡します。間にソースバイトが変わっていれば、編集は出力なしで拒否されます。既存の要素は NodeID のオブジェクト番号でアドレスされ、0 は構造ツリールートを表します

uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;

function TagContentStream(const FileName, OutName: string;
  ElementID, StreamObj, StreamGen: Integer): Boolean;
var
  Source: TMemoryStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  Output: TBytes;
begin
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(FileName);
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);

    SetLength(Ops, 1);
    Ops[0] := Default(TPdfStructureEditOperation);
    Ops[0].Kind := psekAddMarkedContent;
    Ops[0].NodeID := ElementID;            // 既存の要素をオブジェクト番号で
    Ops[0].TargetObjectNumber := StreamObj; // ページのコンテンツストリームかページ Form XObject
    Ops[0].TargetGeneration := StreamGen;

    Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
      Ops, Options, Output, Report);
    if not Result then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
  finally
    Source.Free;
  end;
end;

この 1 つの操作のために、EditPdfDocumentStructure はターゲットストリームを /P <</MCID n>> BDC ... EMC で包み、MCR を要素の /K に追記し、ページ(またはフォーム)に /StructParents キーがなければ与え、そのキーの親ツリー配列に要素 n を書き込みます。結果は元のバイトの後のインクリメンタル更新として書かれ、Report.CreatedMarkedContents が作られた MCR の数を教えます。ストリームがすでに釣り合った BDC/EMC ペアを含む場合、包まれるのは最後のトップレベル EMC の後のタグなしの末尾だけで、そうでなければストリーム全体です

新しい MCID と親ツリーキーはどう選ばれるのか

新しい MCID は現在の最大値に 1 を足したもので、新しい親ツリーキーは Max(MaxKey + 1, ParentTreeNextKey) です。MCID について、EditPdfDocumentStructure は、そのページ(またはその Form XObject)のためにすでに記録された MCR 全体と、ストリーム自身のプロパティリストで見つけたすべての /MCID にわたって最大値を取ります。両方を数えることが重要です。コンテンツがまだマークを運ぶのに構造ツリーが MCR を失っていることがあり、その番号を再利用すれば 2 つの要素が同じコンテンツを請求することになります

キーの論理は同じ防御的な発想に従います。所有者がすでに /StructParents 値を持っていれば、既存の配列が MCID のインデックスで拡張され、隙間は null で埋められ、新しいキーは割り当てられません。持っていなければ、コンポーネントはすべての /Nums リーフを走査して最も高いキーを探し、それを /ParentTreeNextKey と比較し、大きい方を取ります。使用中のキーより低い古い /ParentTreeNextKey も、最後のエントリより先のキーを予約するものも、どちらも処理されます。2 値の大きい方は常に空いているからです。割り当ての後、/ParentTreeNextKey は新しいキーに 1 を足したものへ進みます

Delphi 向け PDFium Component 構造エディタにおける MCID と親ツリーキーの割り当て。新しい MCID は MCR とストリームプロパティリストにすでに記録された最大値に 1 を足したもので、キーは最高の /Nums キーに 1 を足したものと /ParentTreeNextKey の大きい方になり、配列の隙間は null で埋まる
EditPdfDocumentStructure は新しい MCID を MCR とストリーム内マークの両方から導出し、親ツリーキーを防御的に割り当てます。古い /ParentTreeNextKey も再利用された MCID も、2 つの要素に同じコンテンツを請求させられません

新しいペアは、追記ではなく昇順を保って /Nums に挿入されます。ナンバーツリーの探索がその順序に頼るからです。リーフが /Limits を運ぶなら、境界は書き直された配列から再計算されます。構造ツリールートに /ParentTree がまったくない場合、順方向のツリーだけを書いたツールにタグ付けされたファイルではよくあることですが、EditPdfDocumentStructure は新しい間接の << /Nums [] >> オブジェクトを合成し、最初のエントリを挿入する前に /StructTreeRoot からリンクします

Form XObject と /StmOwn、オブジェクト参照

Form XObject の中のマーク済みコンテンツは、そのフォームが正確に 1 ページに使われるときだけ対応されます。フォームのために EditPdfDocumentStructure が書く MCR は /Pg、フォームストリームを名指す /Stm、ページを名指す /StmOwn を運び、/StructParents キーはページではなくフォーム辞書に乗ります。3 ページに描かれるフォームには 3 つの候補所有者がおり、どの単一の /StmOwn もそのうち 2 つには間違いです。コンポーネントは推測する代わりに、エラーで編集を拒否します

Delphi で PDFium Component が Form XObject コンテンツへタグを付ける 1 所有者の規則。1 ページに使われるフォームの MCR は /Pg、/Stm、/StmOwn を運び /StructParents はフォーム辞書に乗るが、3 ページに描かれるフォームは /StmOwn を曖昧にし編集は拒否される
/StmOwn キーが意味を持つのは 1 ページがフォームを所有するときだけです。だからこそ共有フォームと共有注釈は、推測可能な所有者でタグを付ける代わりに拒否されます

同じ一意性規則が psekAddObjectReference にも当てはまります。ターゲットは、正確に 1 つのページの /Annots に現れる注釈か、正確に 1 つのページのリソースに現れる Form か Image の XObject でなければならず、それ以外は拒否されます。OBJR は /Obj と /Pg を受け取り、ターゲットは /StructParent キー(あれば再利用)を受け取り、親ツリー値は要素への素の参照です。これは、視覚的にはタグ付けされたのにツリーに一度も接続されなかったリンク注釈や図版画像への定番の修正です

// タグなしの画像を新しい Figure 要素で包み、2 つのやり方で束ねる:
// 画像 XObject の OBJR として、ページ上のマーク済みコンテンツとして
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // 一時 ID。負で一意でなければならない
Ops[0].ParentNodeID := SectID;    // 既存の Sect 要素
Ops[0].Value := 'Figure';         // 標準ロールか RoleMap エントリ

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // 上で作られたコンテナを参照
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;

Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;

if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
  Output, Report) then
  Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
    Report.InsertedNodes[0].ObjectNumber,
    Report.CreatedObjectReferences, Report.CreatedMarkedContents]));

負の NodeID 値は、同じバッチで先に作られたコンテナを名指しし、Report.InsertedNodes が要求された各 ID を受け取ったオブジェクト番号へ写像します。psekInsertContainer は、P、H1 から H6、Figure、Table、LI のような標準の PDF 1.7、PDF 2.0、PDF/UA ロールと、/RoleMap にすでに写像されたカスタムロールを受け付けます。2 つのコンテンツ操作に BeforeNodeID を設定すると、新しい MCR や OBJR は末尾ではなく既存の子の前に置かれます

同じバッチで同じストリームを 2 回編集するのに注意が要る理由

バッチは同じオブジェクトに繰り返し当たることがあり、後の操作はすべて前の操作の結果を見なければなりません。同じページに対する 2 つの psekAddMarkedContent 操作は、どちらもそのコンテンツストリームを書き直し、どちらも同じ親ツリーリーフを更新します。各編集を別々の追記オブジェクトとしてステージングする実装は、更新の中にオブジェクト 12 の 2 つのバージョンを書きます。リーダーがどちらを保持するか次第で、最初の BDC 包みか最初の /Nums ペアを失い、2 番目の MCID は古いバイトから計算されたため 1 番目と衝突し得ます

EditPdfDocumentStructure はステージングされた更新を (ObjectNumber, Generation) でキー化し、既存のエントリをその場で上書きし、同時にオブジェクトのメモリ内コピーを更新します。したがって 2 番目の操作は、すでに包まれたストリームをパースし、MCID 0 を見て MCID 1 を割り当て、同じ親ツリー配列を拡張します。1 オブジェクト、1 つの最終バージョンです。何かが返される前に、出力は読み戻されてメモリ内モデルと比較されます。要素数、コンテンツアイテム数、保持されたすべての MCR や OBJR が一致しなければ、呼び出しは出力なしで失敗します

圧縮ストリームも同じ経路を通ります。/Filter /FlateDecode(または 1 要素の [/FlateDecode] 配列)を持ち /DecodeParms 予測子のないコンテンツストリームは、32 MiB か MaxOutputBytes の小さい方を上限とする上限付きデコーダで展開され、包まれ、新しい /Length 付きで /FlateDecode へ再圧縮されます。上限を超えて展開されるストリームは切り詰める代わりに拒否されます。信頼できないソースからのファイルでは重要な点です

PDF/UA 修復実行の前に知っておくべき限界

EditPdfDocumentStructure は設計上保守的で、限界のほとんどは静かに壊れたファイルとしてではなく、明確な Report.ErrorMessage として表面化します。バッチ修復を計画するときは次を頭に置いてください

  • 包めるのは無フィルタと素の FlateDecode のストリームだけです。他のフィルタや予測子付き Flate は拒否されます
  • コンテンツストリームは正確に 1 つのページの /Contents に属し、Form XObject、注釈、XObject は正確に 1 つのページに使われなければなりません
  • 包みは常に /P タグを使います。セマンティックロールは BDC タグ名ではなく構造要素の /S から来ます
  • 親ツリーの同期は平坦な /Nums ルートか 1 レベルの /Kids リーフを読みます。より深い中間ノードは下降しないため、そのようなファイルは編集後に再検証してください
  • 署名や署名フィールドを含むファイルは丸ごと拒否されるため、構造修復の後に署名してください。前ではありません

検証のステップは修復パイプラインにとって任意ではありません。バッチの前後で PDFium Component の PDF/UA 構造ツリー検証を実行し、どのページオブジェクトが新しいマークの中に収まったか確認する必要があるときは、BDC タグと MCID を読むためのページオブジェクトのマーク済みコンテンツ APIを使ってください。きれいな親ツリーが語るのは探索が解決することで、読み順や代替テキストが正しいことは語りません

FAQ

EditPdfDocumentStructure は /ParentTree 全体を作り直すのか

いいえ。EditPdfDocumentStructure が更新するのは、現在のバッチでタグを付けるコンテンツのエントリだけで、既存の /StructParents キーを再利用し、必要なところに新しいものを割り当てるため、既存の親ツリーエントリは触られません

PDF に構造ツリーはあるが /ParentTree がないと何が起きるか

コンポーネントは間接の << /Nums [] >> ナンバーツリーを作り、/StructTreeRoot からリンクし、タグを付けるコンテンツの最初のキーを挿入します

複数ページで共有される Form XObject のコンテンツにもタグを付けられるか

この操作ではできません。共有フォームは /StmOwn を曖昧にするため、psekAddMarkedContent はフォームが正確に 1 つのページから参照されることを要求し、そうでなければエラーを報告します

構造ツリーの読み取りと編集、PDF/UA 検証、マーク済みコンテンツへのアクセスは、Delphi、C++Builder、Lazarus プロジェクト向けに PDFium を包む PDFium Component for Delphi and C++Builder の一部です