次の依頼が舞い込む。すでに描画済みの帳票をまとめて黒塗りし、口座番号を隠したうえで、1 枚に 2 ページずつ配置して紙を節約したい、というものだ。どちらの作業も、自分で作成していない PDF に対するコンテンツストリームの外科的編集であり、そこには気軽に描けるページキャンバスも、頼れるフォントマネージャーもない。読み込んだ文書のオブジェクトグラフを直接編集し、別のツールが組み立てたページに生の描画演算子を追記していくことになる。HotPDF には、そのための入り口がちょうど 2 つあり、そのうち見た目は無害でも実際には危険なのが、この話題の中心だ
HotPDF は Delphi と C++Builder 向けのネイティブ VCL PDF コンポーネントだ。ラウンド 9 の読み込み済み文書 API では、作成 した新しいコンテンツを、ディスクから開いたページに追加できる最初のメソッドが加わった。ここで取り上げるのは、そのうちの 2 つだ。RedactLoadedRect は領域の上に不透明な矩形を塗り、StitchLoadedPage は 1 つのページを拡大縮小して別のページに描画する。これらはいずれも、ISO 32000-1 §8.5 のコンテンツストリーム演算子をページの /Contents ストリームに書き込んで動作する。それらの演算子が何をし、同じくらい重要なことに何をしないのかを理解しているかどうかが、動くツールとデータ漏えいの分かれ目になる
読み込み済みページに演算子を追記する
通常の 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
// 1 ページ目の口座番号の帯を純黒で覆う。
// 座標は PDF ユーザー空間: 原点は左下、単位はポイント。
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 - X1 と Y2 - Y1 から導かれるので、対角の 2 点を渡せば広がりを計算させられる。色に 0, 0, 0 を渡せば黒い帯になり、白いページに合わせた白い帯にしたければ 1, 1, 1 を渡せばよい。座標は読み込み済みページ自身のユーザー空間であり、原点が左下、単位がポイントだという意味でもある。つまり、正確に配置したいならページの /MediaBox が必要になる。GetLoadedPageBox と pbMediaBox を使えばそれが得られる
もう一度読む価値がある。塗りつぶした矩形は内容を視覚的に覆うだけで、削除はしない。矩形の下にあるテキスト、画像、ベクターアートは PDF 内に残ったままで、オブジェクトグラフにも残り、ページをコピーしたり、テキスト抽出器を走らせたり、単にコンテンツストリームからその矩形を消したりできる人なら誰でも取り出せる。これは見た目を隠すだけで、法的またはセキュリティ上の意味での redaction ではない。口座番号、医療記録、個人情報のような本当に機密なデータを隠すなら、黒い箱で覆ってファイルを送るのは、いずれ見つかるデータ漏えいに等しい。真の redaction には、上から塗るのではなく、元のコンテンツオブジェクトを削除する必要がある
メソッド名に "Redact" とあるのは、何が削除されたかではなく、結果がどう誤読されやすいかを示す注意だ。実装自身もコメントで正直にそう書いており、自分のことを「視覚的 redaction のプリミティブ」と呼び、内容を消す redaction には、既存の演算子をたどって書き換えるコンテンツストリーム・インタープリタが必要だと述べている。HotPDF の読み込み済み文書パスは、ここではそこまでやらない。したがって安全な使い方は狭い範囲に限られる。RedactLoadedRect 機密性のない見た目のマスキングにだけ使うこと。たとえば下書きのウォーターマークを隠す、スクリーンショットの前に一部を白く塗る、社内校正刷りで古いロゴを覆う、といった用途だ。箱の下にあるものが漏れたら困るなら、その時点でこのメソッドは誤った道具であり、正しい答えはデータを含めずに文書を再生成するか、本物の内容除去パイプラインを使うことだ
StitchLoadedPage: 拡大、移動、描画
N-up 面付けのほうがとっつきやすいのは、隠されるものが何もなく、配置し直されるだけだからだ。StitchLoadedPage は、対象ページのインデックス、ソースページのインデックス、X/Y オフセット、スケール係数を受け取り、その位置とサイズでソースページを対象ページに描画する:
// ページ 2(インデックス 1)をページ 1(インデックス 0)に重ねる。
// 70% に縮小し、右上へ少しずらす。
Pdf.StitchLoadedPage(0, 1, 40, 380, 0.7);
// 簡易 2-up: ソースページを対象ページの右半分に配置する。
Pdf.StitchLoadedPageSideBySide(0, 1);
追加される演算子列は標準的な変換と描画のシーケンスだ。q でグラフィックス状態を保存し、対角にスケールを、平行移動スロットにオフセットを持つ cm 行列、外部オブジェクトを呼び出す /StitchSrc Do、そして Q で状態を復元する。q/Q の組み合わせが重要だ。これが変換を隔離するので、面付けしたページの座標系が、その後に追記された内容へ漏れ出さない。メソッドはありがちなミスも防ぐ。範囲外のインデックス、ソースと同じターゲット、非正のスケール(その場合は 1.0 に丸める)を検出すると、例外は投げずに静かに抜ける。だから入力は確認しておくこと。無音の no-op は成功と見分けがつかない
StitchLoadedPageSideBySide は、一般メソッドを薄く包んだ便宜用のラッパーだ。対象ページのメディアボックス幅を読み取り、それを半分にし、その半分幅を X オフセットに、固定スケール 0.5 を指定して StitchLoadedPage を呼び出し、ソースを右半分に配置する。そのハードコードされた 0.5 は、ソースと対象が同じ幅を共有していることを前提としている。そうでなければソースは半分をきれいに埋められず、両方のメディアボックスから自分で計算したスケールを使う一般の StitchLoadedPage を使いたくなるだろう
簡略化された XObject 戦略と、その ISO 上のトレードオフ
ここが、出力をビューアー間で信頼する前に知っておくべき、実装の意図的な近道だ。正しい N-up 面付けでは、ソースページの内容を Form XObject に包み込む。これは独立して描画できるオブジェクトであり、ISO 32000-1 §8.10.1 では /Type /XObject、/Subtype /Form、そして自前の /BBox クリッピングボックスを持つべきだとされている。HotPDF のラウンド 9 の stitch はそのラッパーを作らない。代わりにソースのページ辞書そのものを、対象の /Resources /XObject の下に名前 StitchSrc で直接登録し、Do で描画する。ページ辞書と Form XObject は、どちらもコンテンツストリームとリソース辞書を参照するという点で内容モデルが十分に似ているため、多くのリーダーは結果を描画する
しかし、それは準拠した Form XObject ではない。/Subtype /Form マーカーと自前の /BBox を欠いているため、厳格なコンシューマーは Do を無視したり、期待とは違う方法でクリップしたりする権利がある。今回の TechnicalNotes ではそれを明言している。つまり、この方法は「多くのリーダーで描画される」一方で「厳密には ISO 準拠の Form XObject ではない」。完全な準拠には、別手順で本物の Form XObject ストリームを組み立てる必要がある。したがって stitch の出力は、どんな非準拠の構造もそうするように扱うこと。顧客が使う特定のビューアーで確認し、自分のマシンだけで済ませない。アーカイブ用途や厳格なバリデーターを通す PDF が必要なら、この経路に頼ってはいけない。同じ厳しさは、読み込んだオブジェクトグラフ上で組み立てるものすべてに当てはまる。だからこそ、文書をプログラムで変更するときはいつでも、Delphi による PDF プリフライト処理 をリリースパイプラインに組み込む価値があるのだ
この 2 つが適合する場所と、しない場所
どちらのメソッドもコンテンツストリームの道具なので、頭の中のモデルは直接描画に使うものと同じだ。コンポーネントでページを最初から作ったことがあれば、これらの呼び出しの背後にあるベクター演算子と色演算子は、Delphi での HotPDF キャンバス描画 でおなじみに見えるだろう。違うのは、ここでは自分が所有するストリームではなく、他人が書いたストリームに追記しているという点だけだ。次の 3 つの境界を頭に入れておこう:
- 墨消しは見た目だけ。
RedactLoadedRectは内容の上に塗るだけで、決して削除しない。機密性のあるものには、ソースを再生成するか本物の内容除去を使うこと。黒い箱はセキュリティではない - Stitch は設計上非準拠。ソースページは §8.10.1 の
/Subtype /Formと/BBoxを持たない疑似 XObject として参照されるので、対象ビューアーでの描画を確認し、厳格な検証が必要な場所では避けること - 座標はページのユーザー空間。左下原点、単位はポイント、ページ自身のメディアボックスに従う。読み込んだページのサイズは想定と違うかもしれないので、配置する前に
GetLoadedPageBoxでボックスを読み取ること
この制約の範囲で使えば、この 2 つで実際のワークフローをまかなえる。印刷用にページを並べ替え、非機密領域をマスキングし、結果を SaveLoadedDocument で書き戻す。しかも完全な再レンダリングは不要だ。これらの stitch と mask のプリミティブを含む読み込み済み文書 API は、同じラウンドのフォームフィールド、注釈、FDF メソッドと並んで、Delphi と C++Builder 向けの HotPDF Delphi Component に同梱されている