技術記事

DelphiでPDFコンテンツストリームを安全に書き換える

HotPDFはページのコンテンツストリーム内のすべての演算子を巡回し、それぞれをVisitLoadedPageContentOperatorsを通じて自分のコードへ渡す。演算子ごとに1つのアクションを選ぶ:保持する、削除する、独自のバイト列で置換する、巡回を停止する、または編集全体を却下する、のいずれかである。すべての演算子が保持された場合、書き換え後のストリームは元のものとバイト単位で完全に同一になる。この性質こそが、自分が作成していないドキュメントに対してもこの仕組みを安全に実行できる理由である

コンテンツストリームの編集は、PDFツールが往々にして誤りを犯す領域である。ストリームはスタックマシン上の演算子の列であり、ドキュメントツリーではない。デコード済みバイト列に対して正規表現を素朴に走らせるアプローチは、文字列リテラルの中に演算子に見えるものが含まれた瞬間、あるいはインライン画像に任意のバイナリが含まれた瞬間に破綻する。すでにストリームをトークン化済みのビジターにはその問題がない

BT・Tf・TJなどのPDFコンテンツストリーム演算子が、フェーズ・演算子名・オペランド数・バイトスパン・ネスト状態を伴うビジターのイベントデータへ流れ込み、保持・置換・削除・停止・却下という5つのアクションのいずれかを返す様子を示す図
訪問した各演算子は正確に1つの決定を生み出し、すべてを保持するパスは変更していない範囲をそのまま書き戻すことでバイト単位で同一の出力を得る

演算子レベルのアクセスで実際に何ができるか

タスクには3つの系統がある。監査:ページが既知の集合外の色を何回設定しているかを数える、あるいは埋め込まれていないフォントで描画されたテキストを見つける。除去:マークコンテンツとして描かれた透かしを削除する、あるいは特定の画像呼び出しを取り除く。そして正規化:ある演算子の形式を、下流の消費者がより良く扱える同等のものに置き換える

これらはいずれも1つの要件を共有する:ストリームの残り99パーセントには一切手を触れないでいられなければならない。HotPDFの構文木はソースのコピーを1つとコンパクトなスパンを保持し、編集はソート済みの重複しないオーバーレイとして扱われる。結果を書き出す際、変更していない範囲は直接コピーされ、置換用バイトは実際に変更した場所にのみ出力される。そのため、何も変更しない往復編集がドリフトを生み出すことはない

ビジター契約

インターフェースを実装するかイベントを割り当て、その上で監査にはApplyChangesをfalseに、書き換えにはtrueに設定して訪問メソッドを呼び出す。イベントデータはフェーズ、演算子名、そのオペランド数、バイトスパン、現在のネスト状態、そしてオペランドを読むための借用ビューを保持する

uses
  HPDFDoc, HPDFContentStream;

procedure TAuditor.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
var
  Bytes: TBytes;
begin
  if EventData.Phase <> covpOperator then
    Exit;

  // marked content内に描画されたtext-showing operatorを数える
  if (EventData.OperatorName = 'Tj') or
     (EventData.OperatorName = 'TJ') then
  begin
    Inc(FTextOperators);
    if EventData.State.MarkedContentDepth > 0 then
      Inc(FTextInsideMarkedContent);
  end;

  // 必要なときだけoperandを読む
  if EventData.OperatorName = 'Do' then
    if EventData.View.GetOperandBytes(0, Bytes) then
      NoteXObjectUse(TEncoding.ANSI.GetString(Bytes));

  EventData.Action := coaKeep;
end;

procedure TAuditor.Run(const FileName: string);
var
  Pdf: THotPDF;
  Stats: THPDFContentOperatorVisitStatistics;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;
    Pdf.OnContentOperator := HandleOperator;
    for I := 0 to Pdf.LoadedPageCount - 1 do
      Pdf.VisitLoadedPageContentOperators(I, False, Stats);   // audit only
    Writeln(Format('%d operators visited, max q/Q depth %d',
      [Stats.VisitedOperatorCount, Stats.MaximumGraphicsStateDepth]));
  finally
    Pdf.Free;
  end;
end;

ビューが可変ではなく借用である理由

コールバックが変更可能な構文木を受け取ることは決してない。受け取るのは、現在の操作のバイト列や特定のオペランドを要求に応じて取得できるビューであり、それがすべてである。すべての変更はアクションフィールドと置換フィールドを経由する

この間接化は単なる儀式ではない。ツリーの直接変更を許せば、ビジターは置換バイト数の予算や重複編集のチェックを回避できてしまい、バイトスパンのオーバーレイモデルにおける重複編集は、大きく破綻するのではなく静かに誤った出力を生み出す。変更をアクションフィールド経由に絞ることで、ビジターの意図にかかわらず、すべての編集が同じ方法で検証される

必要時にのみ読み取ることは、よくあるケースを軽量に保つことにもつながる。演算子名だけを数える監査は、オペランドのバイト列を一切実体化しない。これは、数十万個の操作を持つコンテンツストリームでは重要になる

状態フィールドは常に入力を記述する

各イベントは、その演算子が実行される直前の元の入力における、グラフィックステートの深さ、テキストオブジェクトの深さ、マークコンテンツの深さ、互換性セクションの深さを保持する。これはビジターが以前の演算子を削除または置換していた場合でも変わらない

状態を編集後の出力ではなく元の入力に固定することは、実用上の利点を持つ意図的な選択である:同じドキュメントに対して同じ監査を2回実行すれば同一の結果が得られ、編集と並行して実行される監査も自身の基準フレームをずらすことがない。出力における深さを知る必要があれば、何を削除したかは自分で分かっているので計算できる。逆に、移動し続ける出力から安定した入力位置を再構築することは不可能である

削除と置換、そしてそれを守るルール

置換内容は生のコンテンツストリームバイト列であり、その構文についてはあなた自身が責任を負う。それを取り巻く保護策は、置換バイトの合計に対する予算、重複禁止の要件、そして結果が受理される前に再パースする任意の出力検証である

Delphiにおける書き換えの保護策:ビジターが宣言するActionとReplacementの意図が、置換バイト数の予算・重複しないバイトスパン・出力検証という各ガードを経由して、検証済みの保存ページへ至る様子を示す図
すべての変更はアクションフィールドを経由して予算・重複・再パースの各ガードに流れ込むため、検証に失敗すれば壊れた演算子を書き込む代わりに中断される
procedure TWatermarkStripper.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
begin
  EventData.Action := coaKeep;
  if EventData.Phase <> covpOperator then
    Exit;

  // watermark formをpaintするoperatorをdrop
  if (EventData.OperatorName = 'Do') and OperandIsWatermark(EventData) then
    EventData.Action := coaDelete
  // 迷い込んだcolourをhouse greyにnormalise
  else if EventData.OperatorName = 'rg' then
  begin
    EventData.Replacement := TEncoding.ANSI.GetBytes('0.2 0.2 0.2 rg');
    EventData.Action := coaReplace;
  end
  // 理解できないものに遭遇したらedit全体を拒否
  else if EventData.OperatorName = 'BI' then
    EventData.Action := coaReject;
end;

var
  Options: THPDFContentOperatorVisitOptions;
begin
  Options := THPDFContentOperatorVisitOptions.Default;
  Options.ValidateOutput := True;        // 受け入れる前にreparse
  Options.MaxReplacementBytes := 64 * 1024;

  Pdf.OnContentOperator := HandleOperator;
  if Pdf.VisitLoadedPageContentOperators(PageIndex, True, Options, nil, Stats) then
    if Stats.Modified then
      Pdf.SaveLoadedDocument('cleaned.pdf');
end;

アクションの集合が2つの退出手段を持つのには理由がある。coaStopは巡回を終了し、それまでに決定した内容をそのまま保持する。これは探していたものが見つかった時に適している。coaRejectは訪問全体を放棄し、ページには一切手を触れない。これは書き換えルールがカバーしていない構文に出会った時に適している。インライン画像は典型的な例である:そのバイナリペイロードは、色を正規化するビジターが編集していくべきものではない

統計の読み方、そして巡回が止まる場所

ステータスフィールドは完了、停止、却下、不正な入力、上限超過、不正な出力、そしてビジターエラーを区別する。これらは異なる問題である:不正な出力は置換したバイト列が再パースできなかったことを意味し、不正な入力は元のストリームがすでに不正な形式であり編集が試みられなかったことを意味する

各カウンターは監査を自己検証可能にする。VisitedOperatorCountとKeptOperatorCount、DeletedOperatorCount、ReplacedOperatorCountは合計が一致しなければならず、最大深さの数値は、そのページが詳しく調べる価値があるほど深くネストしているかどうかを示す。SourceBytesとOutputBytesを照らし合わせれば、編集によるサイズへの影響が1つの数値で分かる

保持・削除・置換された演算子数の合計が訪問済み演算子数と一致する監査統計の照合と、完了からビジターエラーまでの7つのステータス終了状態、そして状態の深さが元の入力を記述するため監査が同一に繰り返される旨の注記を示す図
合計が一致しなければならないカウンターと、あり得るすべての終了状態に名前を付けたステータスにより、自分が作成していないドキュメントに対する監査も自己検証可能になる

よくあるタスクに対しては、ビジターを一切書かなくて済む、より高レベルな経路が存在する。本当のリダクションに必要なリソースのクリーンアップとともにコンテンツを永続的に削除する方法はロード済みドキュメントに対するリダクションとページの継ぎ合わせで扱われている。表示されているテキストの置換は既存PDFにおける検索と置換で扱われている。ルールがテキストではなく構造に関するものである場合、あるいは既存のレポートでは答えられない監査が必要な場合は、演算子ビジターに手を伸ばすとよい

コンテンツストリームの巡回、抽出、書き換えはいずれも、DelphiおよびC++Builderにおいて同じロード済みドキュメントモデルに対して動作する。機能一覧の全体はHotPDF Delphi PDFコンポーネントページにある