技術記事

Delphi で HotPDF を使う PDF の墨消しと N-up 面付け

次の依頼が舞い込む。すでに描画済みの帳票をまとめて黒塗りし、口座番号を隠したうえで、1 枚に 2 ページずつ配置して紙を節約したい、というものだ。どちらの作業も、自分で作成していない PDF に対するコンテンツストリームの外科的編集であり、そこには気軽に描けるページキャンバスも、頼れるフォントマネージャーもない。読み込んだ文書のオブジェクトグラフを直接編集し、別のツールが組み立てたページに生の描画演算子を追記していくことになる。HotPDF には、そのための入り口がちょうど 2 つあり、そのうち見た目は無害でも実際には危険なのが、この話題の中心だ

HotPDF は Delphi と C++Builder 向けのネイティブ VCL PDF コンポーネントだ。ラウンド 9 の読み込み済み文書 API では、作成 した新しいコンテンツを、ディスクから開いたページに追加できる最初のメソッドが加わった。ここで取り上げるのは 2 つで、RedactLoadedRect、領域の上に不透明な矩形を塗るStitchLoadedPage、1 つのページを拡大して別のページに描画する/Contents。これらはいずれも ISO 32000-1 §8.5 のコンテンツストリーム演算子をページの

読み込み済みページに演算子を追記する

通常の HotPDF API でページを作成すると、コンポーネントがコンテンツストリームを管理し、あなたの TextOut とベクター呼び出しを直列化してくれる。読み込み済みページは事情が違う: その /Contents は既存のストリームオブジェクトで、共有されている場合もあれば、コンテンツ配列の一部である場合もあり、既存の内容を壊さずにそこへ差し込まなければならない。ラウンド 9 では、それを安全にする 3 つの小さなヘルパーが追加された。NewIndirectStream 新しい間接 THPDFStreamObject を空のバッファーと /Length 0 のエントリ付きで確保し、ResolveLoadedStream で間接参照をたどって元のストリームに到達し、AppendLoadedStream 生バイト列をストリーム末尾に書き込み、/Length も書き換えて保存後のオブジェクトが整合した形を保つ

2 つの公開メソッドがたどる流れは同じだ。ページの /Contents を解決してストリームにし、使えるストリームがなければ新規作成して接続する。その後で演算子を追記する。新しいバイトはストリームの 末尾 に入るので、ペインターのモデルでは元のレイアウトが描いたものすべての上に表示される。この並び順が、墨消し用の矩形を成り立たせる仕組みそのものだ。そして同時に、その矩形が多くの人の思い違いである理由でもある

RedactLoadedRect: 不透明な覆いであって削除ではない

RedactLoadedRect は、0 ベースのページインデックス、4 つのユーザー空間座標、0 から 1 の範囲にある 3 つの色成分を取る:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('statement.pdf') > 0 then
    begin
      // Cover the account-number band on page 1 with solid black.
      // Coordinates are PDF user space: origin bottom-left, points.
      Pdf.RedactLoadedRect(0, 56, 690, 320, 706, 0, 0, 0);
      Pdf.SaveLoadedDocument('statement-covered.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

内部では、このメソッドはコンテンツストリームに 3 つの演算子を出力する。DeviceRGB の塗り色設定(r g b rg)、矩形パス(x y w h re)、そして塗りつぶし(f)。幅と高さは X2 - X1Y2 - Y1 から導かれるので、対角の 2 点を渡せば広がりを計算させられる。色に 0, 0, 0 を渡せば黒い帯になり、白いページに合わせた白い帯にしたければ 1, 1, 1 を渡せばよい。座標は読み込み済みページ自身のユーザー空間であり、原点が左下、単位がポイントだという意味でもある。つまり、正確に配置したいならページの /MediaBox が必要になる。GetLoadedPageBoxpbMediaBox を使えばそれが得られる

もう一度読む価値がある。塗りつぶした矩形は内容を視覚的に覆うだけで、削除はしない。矩形の下にあるテキスト、画像、ベクターアートは PDF 内に残ったままで、オブジェクトグラフにも残り、ページをコピーしたり、テキスト抽出器を走らせたり、単にコンテンツストリームからその矩形を消したりできる人なら誰でも取り出せる。これは見た目を隠すだけで、法的またはセキュリティ上の意味での redaction ではない。口座番号、医療記録、個人情報のような本当に機密なデータを隠すなら、黒い箱で覆ってファイルを送るのは、いずれ見つかるデータ漏えいに等しい。真の redaction には、上から塗るのではなく、元のコンテンツオブジェクトを削除する必要がある

メソッド名に "Redact" とあるのは、何が削除されたかではなく、結果がどう誤読されやすいかを示す注意だ。実装自身もコメントで正直にそう書いており、自分のことを「視覚的 redaction のプリミティブ」と呼び、内容を消す redaction には、既存の演算子をたどって書き換えるコンテンツストリーム・インタープリタが必要だと述べている。HotPDF の読み込み済み文書パスは、ここではそこまでやらない。したがって安全な使い方は狭い範囲に限られる。RedactLoadedRect 機密性のない見た目のマスキングにだけ使うこと。たとえば下書きのウォーターマークを隠す、スクリーンショットの前に一部を白く塗る、社内校正刷りで古いロゴを覆う、といった用途だ。箱の下にあるものが漏れたら困るなら、その時点でこのメソッドは誤った道具であり、正しい答えはデータを含めずに文書を再生成するか、本物の内容除去パイプラインを使うことだ

StitchLoadedPage: 拡大、移動、描画

N-up 面付けのほうがとっつきやすいのは、隠されるものが何もなく、配置し直されるだけだからだ。StitchLoadedPage `StitchLoadedPage` は、対象ページのインデックス、ソースページのインデックス、X/Y オフセット、スケール係数を受け取り、その位置とサイズでソースページを対象ページに描画する:

// Overlay page 2 (index 1) onto page 1 (index 0),
// scaled to 70% and nudged up-right.
Pdf.StitchLoadedPage(0, 1, 40, 380, 0.7);

// Convenience 2-up: source page on the right half of the target.
Pdf.StitchLoadedPageSideBySide(0, 1);

追加される演算子列は標準的な変換と描画のシーケンスだ。q `q` を使ってグラフィックス状態を保存し、cm 対角にスケールを、平行移動スロットにオフセットを持つ /StitchSrc Do `Do` で外部オブジェクトを呼び出し、Q `Q` で状態を復元する。q/Q の組み合わせが重要だ。これが変換を隔離するので、面付けしたページの座標系が、その後に追記された内容へ漏れ出さない。メソッドはありがちなミスも防ぐ。範囲外のインデックス、ソースと同じターゲット、非正のスケール(その場合は 1.01.0

StitchLoadedPageSideBySide に丸める)を検出すると、例外は投げずに静かに抜ける。だから入力は確認しておくこと。無音の no-op は成功と見分けがつかない。StitchLoadedPage は、一般メソッドの薄い便宜ラッパーだ。対象ページのメディアボックス幅を読み取り、それを半分にして、0.5 を X オフセットに、固定スケール StitchLoadedPage0.5

を指定して <code>StitchLoadedPage</code> を呼び出し、ソースを右半分に配置する。そのハードコードされた 0.5 は、ソースと対象が同じ幅を共有していることを前提としている。そうでなければソースは半分をきれいに埋められず、両方のメディアボックスから自分で計算したスケールを使う一般の <code>StitchLoadedPage</code> を使いたくなる

簡略化された XObject 戦略と、その ISO 上のトレードオフ/Type /XObjectここが、出力をビューアー間で信頼する前に知っておくべき、実装の意図的な近道だ。正しい N-up 面付けでは、ソースページの内容を Form XObject に包み込む。これは独立して描画できるオブジェクトであり、ISO 32000-1 §8.10.1 では /Subtype /Form/Type /XObject/BBox/Subtype /Form、そして自前の /Resources /XObject/BBoxStitchSrc クリッピングボックスを持つべきだとされている。HotPDF のラウンド 9 の stitch はそのラッパーを作らない。代わりにソース Doページ辞書そのもの

を対象の /Subtype /Form/Resources /XObject/BBox の下に名前 DoStitchSrcで直接登録し、Do

で描画する。ページ辞書と Form XObject は、どちらもコンテンツストリームとリソース辞書を参照するという点で内容モデルが十分に似ているため、多くのリーダーは結果を描画する

しかし、それは準拠した Form XObject ではない。/Subtype /Form マーカーと自前の

  • /BBox RedactLoadedRect を欠いているため、厳格なコンシューマーは
  • Do を無視したり、期待とは違う方法でクリップしたりする権利がある。今回の TechnicalNotes ではそれを明言している。つまり、この方法は「多くのリーダーで描画される」一方で「厳密には ISO 準拠の Form XObject ではない」。完全な準拠には、別手順で本物の Form XObject ストリームを組み立てる必要がある。したがって stitch の出力は、どんな非準拠の構造もそうするように扱うこと。顧客が使う特定のビューアーで確認し、自分のマシンだけで済ませない。アーカイブ用途や厳格なバリデーターを通す PDF が必要なら、この経路に頼ってはいけない。同じ厳しさは、読み込んだオブジェクトグラフ上で組み立てるものすべてに当てはまる。だから、 そのソースページは、§8.10.1 の /Subtype /Form/Subtype /Form/BBox
  • /BBox がない疑似 XObject として参照されるので、対象ビューアーでの描画を確認し、厳格な検証が必要な場所では避けること。GetLoadedPageBox座標はページのユーザー空間だ

左下原点、単位はポイント、ページ自身のメディアボックスに従う。SaveLoadedDocument配置する前にボックスを読み取ること。読み込んだページのサイズは、あなたの想定と違うかもしれないからだ。この制約の範囲で使えば、この 2 つで実際の作業をまかなえる。印刷用にページを並べ替え、非機密領域を隠し、結果を書き戻す。しかも完全な再レンダリングは不要だ。これらの stitch と mask のプリミティブを含む読み込み済み文書 API は、同じラウンドのフォームフィールド、注釈、FDF メソッドと並んで HotPDF Component