技術記事

DelphiのPDFiumPasでオペレータ単位のPDF墨消し

誰かが名前の上に黒い箱を描き、何もフラット化せずにファイルを出荷すると、レビュアーは矩形を選択して名前をメールに貼り付けます。PDFiumPasはこれにオペレータ単位の墨消しで答えます。SaveAsRedactedは、文字ボックスが墨消し矩形に触れるUnicodeスカラーだけを削除し、生存文字を元のフォント、サイズ、マトリクス、レンダリングモード、色から再構築し、軸整列のパスと画像は丸ごと捨てるのではなく切り詰めます

描かれた矩形が墨消しにならない理由

コンテンツストリームの上に追加された描画オペレーションは何も隠しません。その下のテキスト表示オペレータがまだストリームにあり、まだコードポイントへマップするからです。ISO 32000-1 §9.4はテキストオブジェクトをBTETの内側の、配置と表示のオペレータの列として定義します。その後で描かれた塗り矩形は、同じストリームのもう1つのオペレータにすぎません。抽出はピクセルではなくオペレータを歩くので、覆われた文字列は無傷で戻ってきます。本物の墨消しは出力を覆い隠すのではなく、オペランドを取り除かなければなりません

素朴で安全な実装は乱暴です。バウンディングボックスが墨消し矩形と交差するページオブジェクトをすべて見つけ、オブジェクトごと削除します。以前のPDFiumPasリリースはまさにそれをしており、正しいけれど代償が大きい。1つのTjはテーブル行全体を運べるので、口座番号を1つ黒くすると日付、摘要、金額も道連れになりました。たまたま全幅のテーブル帯だった矩形の塗りつぶしは、ページ全体から消えました。請求書ロゴは墨消しがその一角を刈っただけなのに姿を消しました。バージョン3.101.0はこの決定を1段下げ、ページオブジェクトからオペランドへ移します

オペレータ単位の墨消しは実際に何を削除するのか

PDFiumPasが削除するのはUnicodeスカラーであってテキストオブジェクトではありません。SaveAsRedactedの間、コンポーネントは読み込まれたテキストページから文字とページオブジェクトのマッピングを構築し、検査対象オブジェクトが所有する各文字について文字ボックスを読み、そのボックスを各墨消し矩形と交差させます。矩形に触れる文字は削除候補として印が付き、残りは生存者として印が付きます。何も交差しなければオブジェクトは完全に放置され、すべての文字が交差すればオブジェクトは以前と同じように丸ごと削除されます。分割が起きるのは混在ケースだけです

DelphiにおけるPDFiumPasのオペレータ単位の墨消しとオブジェクト全体削除の比較。旧経路は口座番号1つが覆われただけでテキストオブジェクト全体を落とし、分割経路は交差する文字だけを削除して各生存文字を独自のテキストオブジェクトとして再出力する
分割が起きるのは混在ケースだけです。何も交差しなければオブジェクトは放置され、すべて交差すれば丸ごと削除されます

各生存文字はその後、独自のテキストオブジェクトとして再出力されます。それは元のフォントハンドル、元のフォントサイズ、文字ごとのテキストマトリクス、元のテキストレンダリングモード、そしてストローク幅、ラインジョイン、ラインキャップ、ダッシュ配列を含む親オブジェクトのフィルとストロークの状態から組み立てられます。新しいフォントを解決するのではなくフォントハンドルを再利用するのがグリフをメトリック的に同一に保つ鍵であり、文字ごとのマトリクスを再利用するのがレイアウトを再実行せずにカーニングと語間を保つ鍵です。代償はオブジェクト数です。保持された1文字が1つのテキストオブジェクトになるため、TPdfRedactionOptions.MaxSplitObjectsが生成断片のハード上限として存在します

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // ファイルはすでに /Redact アノテーションを運ぶ
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // オペレータ単位の分割(デフォルト)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // 生成断片の上限

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // フェイルクローズ、出荷しない
  finally
    Pdf.Free;
  end;
end;

矩形は切り詰められ、回転したジオメトリはされない

パスが分割されるのは、PDFiumPasがそのパスが軸整列の矩形だと証明できるときだけです。証明は意図的に狭い。オブジェクトマトリクスの両シアー項が0.0001未満であること、パスがMOVETOで始まりLINETOだけが続く4〜6セグメントで構成されること、変換された点が0.01の許容内でオブジェクト境界の4隅すべてに着地すること。この検査を通ったパスは矩形減算の連続で縮小され、各墨消し矩形が生存集合を左、右、下、上の帯に切り分け、生じた各帯は元のフィルモード、ストロークフラグ、ペイント状態で再作成されます。曲線、三角形、クリップされた図形、回転したものはすべて検査に落ちて、オブジェクトは丸ごと削除されます

画像はISO 32000-1 §8.9に従います。画像サンプルは単位正方形を占め、現在の変換マトリクスを通してマップされます。PDFiumPasはそのマッピングを逆転させ、生存したページ空間の各断片を正規化画像座標へ戻し、単位区間へクランプし、それから内側へ丸めてピクセルインデックスへ変換します。左と上の縁はCeil、右と下はFloorです。方向が重要です。外側へ丸めると、墨消し側のソースピクセルの半端な列が断片の縁で生き残り得ます。整数ピクセル境界は正規化座標へ戻され、断片マトリクスの導出に使われます。だから切り詰められたビットマップは切り取られたピクセル境界の上に正確に着地します。切り詰め自体は、Gray、BGR、BGRx、BGRAの各形式にわたるストライド認識の行コピーです。パスと同様に、回転またはスキューした画像、退化したスケール項を持つマトリクスの画像は丸ごと削除されます

DelphiでPDFiumPasが部分的に墨消しされた画像を切り詰める方法。生存したページ空間断片は逆CTMを通して正規化画像座標へ戻され、単位区間へクランプされ、内側へ丸められるため、墨消しされたピクセル列は生き残らない
左と上はCeil、右と下はFloor。だから切り口は完全なピクセル境界に着地します
// SaveAsRedacted 呼び出しが成功した後
Writeln(Format('applied %d redaction(s) on %d page(s)',
  [Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
  [Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
  [Report.SplitTextObjectCount, Report.SplitPathObjectCount,
   Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
  [Report.ResourcePruneReport.RemovedNameCount,
   Report.ResourcePruneReport.RemovedObjectCount]));

if Report.PreservedFragmentCount = 0 then
  // 何も分割できなかった: 交差したオブジェクトはすべて丸ごと削除
  LogWholeObjectFallback(SourcePdf);

PDFiumPasはマップされていない文字でなぜフェイルクローズするのか

再現可能なUnicodeスカラーを持たないグリフは正直に再構築できないからです。生存文字の再構築は文字列とともにテキスト設定APIを呼ぶことであり、保持されたすべての文字に安定したコードポイントが必要です。壊れたまたは欠落したToUnicodeデータを持つシンボリックサブセットフォントは空のマッピングを返し得ますし、当て推量での再エンコードは、下に異なる文字を運んだまま画面上では正しく見える出力を作ります。PDFiumPasは拒否します。保持文字チェックが例外を投げ、例外はSaveAsRedactedの内側で捕捉され、TPdfRedactionReport.SucceededErrorMessageにメッセージを載せてFalseで戻り、関数はFalseを返します。同じ規則が分割予算にも適用され、断片集合を静かに切り詰めるのではなく例外を投げます。信頼できないフォントを含む文書で決定論的な旧動作が欲しいなら、Options.PreservePartialObjects := Falseを設定してください。交差したオブジェクトはすべて丸ごと消えます

共有スコープ間のリソース刈り込み

オブジェクトの分割は孤児を残します。そしてその刈り込みは、ページレベルの/Resources辞書を差分するほど単純ではありません。ISO 32000-1 §7.8.3は、同じリソース辞書が複数のページ、フォームXObject、パターン、アノテーションアピアランスストリームから同時に参照されることを許します。あるページが使い止めたからとフォント名を削除すると、まだ使っている別のページが壊れます。そこでPruneUnusedPdfResourcesはスコープごとに働きます。/Contentsが直接配列であれ、配列への間接参照であれ、単一ストリームであれ解決し、実際にリソースを名指しするオペレータからリソース使用を収集します。フォントはTf、XObjectはDo、グラフィックス状態はgs、カラースペースとパターンはCScsSCNscn、シェーディングはsh、マーク付きコンテンツプロパティはBDCDP、さらにインライン画像の/CSエントリです。1つの辞書が複数スコープで共有されるとき、使用名集合は何かを削除する前にカテゴリごとにunionされます

PDFiumPasのリソース刈り込み。3つのスコープが1つの共有リソース辞書を参照し、使用名集合はカテゴリごとにunionされ、どのスコープも参照しない名前だけが削除されてから到達不能オブジェクトが清掃される
1つの辞書が複数のページ、フォームXObject、アピアランスストリームに仕えることがあります。だからPDFiumPasは1つも名前を落とす前にすべての使用名集合をunionします

辞書を指すすべてのスコープで未参照と確認された名前だけが削除されます。自信を持って解析できないスコープは手つかずで残されます。それが保守的な方向です。刈り込み不足のファイルは大きいだけで、誤って刈り込まれたファイルは壊れています。生存した辞書は正確なジェネレーション番号を運ぶ疎なインクリメンタル更新として書き戻され、その後の到達可能性リライトが、名前の消失で到達不能になったオブジェクトを清掃します。TPdfResourcePruneReportScannedScopeCountUpdatedScopeCountRemovedNameCountRemovedObjectCount、バイト数、Succeededフラグを報告します。SaveAsRedactedは洗浄済み出力に対してこのステップを自動実行するので、墨消し経路にはすでに含まれていますが、単独で欲しいパイプラインのためにストリームレベルでエクスポートされています

uses
  FPdfCompress;

procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
  Source, Dest: TFileStream;
  Report: TPdfResourcePruneReport;
begin
  Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create(TargetPdf, fmCreate);
    try
      // AllowSignedDocument は False のまま: インクリメンタル書き換えは
      // 署名が覆うバイト範囲を無効化する
      PruneUnusedPdfResources(Source, Dest, Report);
      if not Report.Succeeded then
        raise Exception.Create(Report.ErrorMessage);
      Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
        [Report.RemovedNameCount, Report.UpdatedScopeCount,
         Report.SourceByteCount, Report.OutputByteCount]));
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

文書パイプラインへの組み込み

墨消し経路は読み込んだ文書を決して書き換えません。SaveAsRedactedは分離されたスナップショットを取得し、そこに/Redactアノテーションを適用し、添付を剥がし、オープンアクション、カタログアクション、名前ツリー、関連ファイル、AcroForm、メタデータを除去する洗浄パスを実行し、リソースを刈り込み、それからようやく出力ストリームを書きます。その出力を独立した文書として再オープンしテキストを再抽出することが、自分のテストスイートに残す価値のある検証ステップです。元の問い——読者はまだその文字列を取れるか——に答える唯一の検査だからです。計画すべき帰結が1つあります。分割はページオブジェクトを置き換えるので、保持していたFPDF_PAGEOBJECTハンドルは後で無効です。これは変換後の失効したページオブジェクトハンドルで述べたのと同じライフタイムの罠です

隣接する2つのピースがワークフローを完成させます。墨消し矩形をどこへ置くかの決定は通常、抽出されたジオメトリから始まり、構造化テキストブロックと読み順のブロックと読み順モデルは、生の文字列より良い候補ボックスの供給源です。結果をレビュアーに提供することは安全なPDFプレビューの構築のハードニング規則に属し、そこではフォーム入力とJavaScriptがデフォルトでオフのままです。揃えて、ほとんどのコンプライアンスワークフローが必要とするループを覆います。位置特定、オペレータ単位の墨消し、再オープンによる検証、安全なプレビュー。完全なAPIサーフェス、トライアルダウンロード、コンポーネントのライセンス条項はPDFium Delphi Componentの製品ページにあります