技術記事

タグ付きPDFの注釈削除:OBJRとParentTreeのクリーンアップ

ページだけを扱うエディタでタグ付き PDF から Link 注釈を取り除くと、ページは正常に描画されるのに、構造ツリーには消えたはずの注釈向けの OBJR と ParentTree の項目、Link 要素が残ったままになります。PDFium Component の RemovePdfPageAnnotations は注釈をページから引き下ろし、オプション指定があればそれらの構造部品を一緒に取り除きます

タグ付き注釈は構造ツリーのどこに配線されるのか

タグ付き注釈は 4 つのオブジェクトが交わる接合点に位置し、きれいな削除はそのすべてを訪れる必要があります。ISO 32000-1 §14.7.4.3 は、構造要素のコンテンツが注釈のような PDF オブジェクト丸ごとであるとき、その要素の /K 配列がオブジェクト参照辞書(Table 325)でそれを名指しすると定めています。/Type /OBJR、注釈を指す必須の間接参照 /Obj、そして要素自身のページを上書きする任意の /Pg です。ここまでがツリーからページへ向かう順方向です

Delphi 向け PDFium Component で、タグ付き Link 注釈が PDF ページの Annots 配列と OBJR オブジェクト参照、StructParent キー、ParentTree 項目にどう接続するか
タグ付き注釈は 4 つのオブジェクトに配線されており、きれいな削除はページ参照と OBJR、StructParent キー、ParentTree のペアを訪れます

逆方向の経路が §14.7.4.4 です。親ツリーはナンバーツリー(§7.9.7)で、コンテンツアイテムであるすべてのオブジェクトについて、そのオブジェクトが運ぶ整数をキーに項目を持ちます。注釈は /StructParent を運び、その親ツリーの値は所有要素への単一の間接参照です。コンテンツストリームにマーク付きコンテンツを抱えるページは代わりに /StructParents を運び、その値は MCID をインデックスとする配列です。Table 326 がどのオブジェクトにも許すのはこの 2 つのキーのうち最大 1 つです。オブジェクトはコンテンツアイテム丸ごとであるか、マーク付きコンテンツの容器であるかのどちらかだからです。ハイパーリンクについては、§14.8.4.4.2 が Link 要素に、マーク付きコンテンツ参照(MCR)としての目に見える語句と、リンク注釈への OBJR の両方の保持を許します。注釈を削除すると、残るものの一覧はこうなります

位置保持しているものきれいな削除がすること
ページの /Annots注釈への間接参照参照を外す。残りの注釈は順序を保つ
Link 要素の /K/Obj と /Pg を持つ OBJROBJR コンテンツアイテムを取り除く
注釈辞書/StructParent の整数キーキーを取り除く。オブジェクトはもうコンテンツアイテムではないため
ParentTree の /Nums リーフLink 要素への参照と対になるキーキーと値のペアを取り除く
構造ツリーのルート/ParentTreeNextKeyそのまま触らない

幽霊リンクはなぜ注釈の後まで生き残るのか

ページレベルのエディタは注釈を /Annots から外すことで削除し、その後の行方は 2 通りのどちらかです。ツールが注釈オブジェクトも解放するなら、OBJR の /Obj は未定義オブジェクトを指すことになり、§7.3.10 はリーダーにそれを null として扱えと告げます。Link 要素は何にも解決しないコンテンツアイテムを抱えたままです。ツールがオブジェクトをファイルに残すなら、OBJR はまだ解決しますが、その /Pg が名指すページの /Annots はもうそれを載せていません。どちらの場合も Link 要素はマーク付きコンテンツの子を保持するため、「Download the datasheet」という語は今もリンクとしてタグ付けされており、読み順にツリーを歩くスクリーンリーダーは、ページ上の何もアクティブにできないリンクを読み上げかねません。ISO 14289-1 に対して検証する PAC や veraPDF といった PDF/UA チェッカーは、ページが載せるものとツリーが主張するものを突き合わせますから、描画されたページが完璧に見えるときでも、この食い違いはそこで表面化し得ます

ページだけの削除の後にタグ付き PDF に幽霊リンクを残す 2 つの経路と、Delphi の PDFium Component がそれらをきれいにする方法
注釈オブジェクトを解放してもファイルに残しても、Link 要素は語句を保持したまま、スクリーンリーダーは行き先のないリンクを読み上げ得ます

コンテンツの削除にもこの問題の別バージョンがあります。/P <</MCID 4>> BDC … EMC の内側のテキストオブジェクトを、オペレータを残したまま取り除くと、ページは空のマーク付きコンテンツ列を抱え、その MCR は <P> 要素に座ったままです。リーダーが受け取るのは語のない段落です。BDC だけを取り除いて EMC を残すと、コンテンツストリームは丸ごと壊れます。§14.6 はすべてのマーク付きコンテンツ列が、単一のコンテンツストリーム内で BDC か BMC と対応する EMC で括されることを要求します。孤児の EMC は、ページ上のそれ以降のすべての列を間違った開始子と組にしたまま置きます

RemovePdfPageAnnotations で注釈を取り除く

FPdfDocumentStructure の RemovePdfPageAnnotations は、ソースストリームとページツリー順のゼロ起点ページインデックス、取り除く注釈のオブジェクト番号、TPdfAnnotationRemovalOptions レコード、そして新ファイルと TPdfAnnotationRemovalReport の 2 つの out パラメータを取ります。TPdfAnnotationRemovalOptions.Default で触るのはページだけです。1 つの増分リビジョンが、短くなった /Annots 配列を持つページ辞書(最後の注釈が消えたら /Annots キーごと消える)と、変わらないカタログを運びます。オブジェクト番号も文書 ID も、ページの他のキーもみな生き残ります。注釈オブジェクトは本体を保持し、番号を握ったままの呼び出し元はそれを読めます。構造ツリーは元のままです。Link 要素を後継の注釈に向け直そうというケースでは、タグ付けが無傷で保たれます。何かを書き出す前に入力は検査されます

  • 各番号は選択されたページの /Annots に現れ、/Type /Annot オブジェクトを名指していなければなりません。1 回の呼び出しで 2 回載った番号は拒否されます
  • ウィジェット注釈は拒否されます。ウィジェットはフォームフィールドに属しており、ページから引き剥がすと、どのページにもウィジェットを表示されないフィールドが残るためです
  • 暗号化されたソースは拒否されます。構造編集は復号されたバイトを必要とするので、ファイルは事前に復号してください
  • MaxOutputBytes と MaxWork、CancellationToken が作業を区切り、失敗はいかなるときも出力なしの False と、停止した局面を名指す ErrorMessage を返します

タグ付き文書なら、どの注釈番号が重要かは構造ツリーがすでに教えてくれます。ReadPdfDocumentStructure のスナップショットに現れるすべての OBJR アイテムは参照先のオブジェクト番号を運び、ページ番号は OBJR 自身の /Pg から、だめなら要素のものから来ます。下のルーチンは、1 ページ上の Link 要素が参照する注釈を全部集め、後始末オプションを 2 つともオンにして取り除きます。ページフィルタは守ってください。別のページから取った番号はバッチ全体を拒否させます

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

function RemoveTaggedLinks(const InName, OutName: string;
  PageIndex: Integer): TPdfAnnotationRemovalReport;
var
  Source: TMemoryStream;
  Pages: TPdfSparsePageDictionaries;
  ReadOptions: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  ReadReport: TPdfStructureEditReport;
  Item: TPdfDocumentStructureContentItem;
  Targets: array of Integer;
  Options: TPdfAnnotationRemovalOptions;
  Report: TPdfAnnotationRemovalReport;
  Output: TBytes;
  N, I, J: Integer;
  Known: Boolean;
begin
  Report := Default(TPdfAnnotationRemovalReport);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(InName);
    if not ReadPdfSparsePageDictionaries(Source, Pages) or
       (PageIndex < 0) or (PageIndex > High(Pages)) then
      raise Exception.Create('Page index is outside the page tree');
    ReadOptions := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
      raise Exception.Create(ReadReport.ErrorMessage);

    // このページの Link 要素が指すすべての注釈
    for N := 0 to High(Snapshot.Nodes) do
      if Snapshot.Nodes[N].RoleName = 'Link' then
        for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
        begin
          Item := Snapshot.Nodes[N].ContentItems[I];
          if (Item.Kind <> psckObjectReference) or
             (Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
            Continue;
          Known := False;
          for J := 0 to High(Targets) do
            if Targets[J] = Item.ReferencedObjectNumber then Known := True;
          if not Known then
          begin
            SetLength(Targets, Length(Targets) + 1);
            Targets[High(Targets)] := Item.ReferencedObjectNumber;
          end;
        end;
    if Length(Targets) = 0 then
      Exit(Report);

    Options := TPdfAnnotationRemovalOptions.Default;
    Options.RemoveOwnedStructurePeers := True;    // OBJR アイテムと ParentTree の項目
    Options.RemoveEmptiedStructureNodes := True;  // 中身を空にされた要素
    if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
      Output, Report) then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
    Result := Report;
  finally
    Source.Free;
  end;
end;

ParentTree のキーはどうなるのか

RemoveOwnedStructurePeers を設定すると、/Obj がそのページで取り除かれた注釈である各 OBJR は、構造エディタを通して要素から出て行き、逆インデックスは歩調を保ちます。注釈の /StructParent の下に格納されたキーと値のペアは /Nums リーフから取り出され、/StructParent キー自身も注釈辞書から取り除かれます。Table 326 がこのキーを求めるのは、構造上のコンテンツアイテムであるオブジェクトだけだからです。/ParentTreeNextKey は引き下げられません。§14.7.4.4 が要求するのは使用中のすべてのキーより大きいことだけで、削除でそれが破られることはなく、ナンバーツリーのキーは連続である必要もないので、/ParentTreeNextKey に従うライターが解放されたキーを再度配ることはありません。生き残った注釈はどれもオブジェクト番号と /Annots 内の位置、ParentTree のキーを保持します。関数は書き込んだページを再読み込みし、生き残りが消えたり場所を変えたりしていれば結果を拒否します。期待値はこうです。タグ付きリンク 1 個のページでは Report.RemovedAnnotations と Report.RemovedObjectReferences がともに 1 で、Report.SourceSha256 と Report.OutputSha256 がどのバイトが入ったか、どのバイトが出たかを記録します

空になった要素はいついっしょに消すべきか

RemoveEmptiedStructureNodes は、OBJR の除去によってコンテンツアイテムも子要素も持たなくなった要素を、深いほうから取り除きます。ラウンド制で動きます。各ラウンドは構造を再読み込みし、空になった要素を取り除き、その親を次のラウンドの候補に加えます。ラッパーの連鎖は下から上へ崩れていき、構造ツリーのルートだけは常に残ります。付箋をタグ付けする Annot 要素は通常 OBJR しか持たないので(§14.8.4.4.3 がこれを許します)、空になって消えます。Link 要素は違います。目に見える語句のマーク付きコンテンツ参照をまだ抱えているので空ではなく、注釈のない Link として生き残ります。RemovePdfPageAnnotations は名前を変えません。その語句がリンクのままであるべきかは、構造上ではなく編集上の問いだからです。純粋な削除では、2 つのオプションをオンにしてから、OBJR を失った Link 要素を Span に降格させてください。支援技術はその語句を行き先のないリンクとして提示する代わりに、普通のテキストとして読み上げます

空になった構造要素の判断フロー:Annot 要素はラウンドごとに崩れ、Link 要素は生き残り、PDFium Component は Span への降格もできる
空になったラッパーはルートを残して深いほうから崩れ、OBJR を失った Link は Span へ降格できるし、後継注釈のために取っておくこともできます
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
  Source: TBytesStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  N: Integer;
begin
  Result := 0;
  Fixed := Data;
  Source := TBytesStream.Create(Data);
  try
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);
    for N := 0 to High(Snapshot.Nodes) do
      if (Snapshot.Nodes[N].RoleName = 'Link') and
         (Snapshot.Nodes[N].ObjectReferenceCount = 0) and
         (Length(Snapshot.Nodes[N].ContentItems) > 0) then
      begin
        SetLength(Ops, Length(Ops) + 1);
        Ops[High(Ops)] := Default(TPdfStructureEditOperation);
        Ops[High(Ops)].Kind := psekSetRole;
        Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
        Ops[High(Ops)].Value := 'Span';
      end;
    if Length(Ops) = 0 then Exit;
    // スナップショットのダイジェストが、その間のバイト変化を防ぐ
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

削除が置き換えの半分であるときはデフォルトのままにしてください。その場に残された空の要素は、ロールとタイトル、代替テキスト、要素 ID をまだ運んでいます。後のステップが psekAddObjectReference オペレーションで新しい注釈をそこに付けると、注釈は新しい /StructParent キーを受け取り、/ParentTreeNextKey は新しい MCR と OBJR アイテムを親ツリーに登録するのと同じルールで進み、要素は読み順での居場所を保ちます。その要素を取り除いて作り直すと、これらの値は捨てられ、新しいオブジェクト番号が与えられます

タグ付きコンテンツの削除:所有権は BDC から対応する EMC まで

注釈はページオブジェクトではないので、テキストとパス、画像は別の API を通ります。バッチオブジェクトエディタの PlanStyledPdfObjectEdits と ExecuteStyledPdfObjectEdits、どちらも FPdfStyledEdit の関数で、その TPdfObjectEditOptions は同じ意味の RemoveOwnedStructurePeers と RemoveEmptiedStructureNodes を運びます。タグ付きのマーク付きコンテンツ列が構造エントリごと消えるのは、その BDC と対応する EMC の間のオブジェクトがすべて削除されるときだけです。その場合、両オペレータはコンテンツストリームを去り、MCR は要素を去り、ページの ParentTree 配列におけるその MCID のスロットは詰められる代わりに null を設定されます。ページ上の他の MCID はみな同じ配列をインデックスにするからです。生き残るコンテンツも巻き込む列は、MCR ごと残ります。取り除いた MCID を生き残るコンテンツに再利用するページは丸ごと拒否されます。MCID はページのコンテンツストリームにスコープされ、構造エントリを落とすと生き残りが孤児になるためです。オプションを False のままにすると、バッチは代わりに除去境界の BDC と EMC のペアを保持するので、構造ツリーは解決を保ち、中身は何も変わりません

所有権は BDC から対応する EMC の終わりまでを測る必要があります。初期バージョンでは、パース済みマークが位置を記録するその BDC オペレータのバイト範囲だけを測り、囲まれたオブジェクトをまったく見つけられませんでした。関連する過ちが、BDC を削除対象に印を付けて相方を見失うことです。これはまさに上で述べた不均衡ストリームを書き出すので、エディタはペアの両半分を必ずいっしょに取り除きます。下のルーチンは、1 つの MCID を持つトップレベルオブジェクトをすべて削除します。EditOptions.PreviewOnly が設定されていなければ ExecuteStyledPdfObjectEdits は結果で Pdf を再ロードし、Report.RemovedStructurePeers と Report.RemovedStructureNodes がツリーから出て行ったものを数えます。関数はそのレポートを返します

uses
  System.SysUtils, PDFium, FPdfStyledEdit;

function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
  Ops: TPdfObjectEditOperations;
  NoSemanticEdits: TPdfObjectSemanticEdits;
  Options: TPdfObjectEditOptions;
  Plan: TPdfObjectEditPlan;
  Preview: TBytes;
  Report: TPdfObjectEditReport;
  I: Integer;
begin
  Pdf.PageNumber := PageIndex + 1;          // PageNumber は 1 起点
  for I := 0 to Pdf.ObjectCount - 1 do
    if Pdf.PageObjectMarkedContentID(I) = Mcid then
    begin
      SetLength(Ops, Length(Ops) + 1);
      Ops[High(Ops)] := Default(TPdfObjectEditOperation);
      Ops[High(Ops)].Kind := poekRemove;
      Ops[High(Ops)].ObjectIndex := I;
    end;
  if Length(Ops) = 0 then
    raise Exception.CreateFmt('No object on page %d carries MCID %d',
      [PageIndex + 1, Mcid]);
  NoSemanticEdits := nil;
  Options := TPdfObjectEditOptions.Default;
  Options.RemoveOwnedStructurePeers := True;
  Options.RemoveEmptiedStructureNodes := True;
  Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
  if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Result := Report;
end;

どちらの呼び出しの後でも、取り除かれた注釈はファイルの中で自分のオブジェクトを占有し続けます。引き下げられるのはページ参照だけだからです。OBJR が消えた後はカタログのグラフのどこからも届かないので、TPdf.SaveAsReachable が書き直したコピーからその孤児オブジェクトを一掃できます。デフォルトのオプションでは OBJR が注釈を到達可能に保つので、一掃もそれを残します。削除が最終であるときは、いつでも 2 つの構造オプションをオンにして実行してください。そうすればDelphi 向け PDFium Component が書き戻すファイルの中で、ページと構造ツリー、親ツリーが同じ注釈集合を記述します