ページだけを扱うエディタでタグ付き PDF から Link 注釈を取り除くと、ページは正常に描画されるのに、構造ツリーには消えたはずの注釈向けの OBJR と ParentTree の項目、Link 要素が残ったままになります。PDFium Component の RemovePdfPageAnnotations は注釈をページから引き下ろし、オプション指定があればそれらの構造部品を一緒に取り除きます
タグ付き注釈は構造ツリーのどこに配線されるのか
タグ付き注釈は 4 つのオブジェクトが交わる接合点に位置し、きれいな削除はそのすべてを訪れる必要があります。ISO 32000-1 §14.7.4.3 は、構造要素のコンテンツが注釈のような PDF オブジェクト丸ごとであるとき、その要素の /K 配列がオブジェクト参照辞書(Table 325)でそれを名指しすると定めています。/Type /OBJR、注釈を指す必須の間接参照 /Obj、そして要素自身のページを上書きする任意の /Pg です。ここまでがツリーからページへ向かう順方向です
逆方向の経路が §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 を持つ OBJR | OBJR コンテンツアイテムを取り除く |
| 注釈辞書 | /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 チェッカーは、ページが載せるものとツリーが主張するものを突き合わせますから、描画されたページが完璧に見えるときでも、この食い違いはそこで表面化し得ます
コンテンツの削除にもこの問題の別バージョンがあります。/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 に降格させてください。支援技術はその語句を行き先のないリンクとして提示する代わりに、普通のテキストとして読み上げます
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 が書き戻すファイルの中で、ページと構造ツリー、親ツリーが同じ注釈集合を記述します