技術記事

PDFコンテンツストリームのidentity Tm:安全なピーフホール削除

PDF Library for Delphiは、保存時のピーフホールによるコンテンツストリーム最適化の中で、identityのテキストマトリクス演算子1 0 0 1 0 0 Tmを削除します。ただしテキストマトリクスとテキストラインマトリクスがすでにidentityのときだけです。BTの直後、あるいは先行するidentityのTmの直後がそれに当たります。identityのcmは今も常に削除されます。cmはCTMを乗算するのに対し、Tmは2つのテキストマトリクスをまるごと置き換えるからです。v3.539.28からは、それ以外のidentity Tmはすべてストリームに残ります

今回直したバグは静かなタイプです。レポートジェネレーターがBT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ETを出す。identityのTmに頼って、2番目の文字列をテキスト空間の原点へ戻してから、独自の配置ロジックを適用するつもりです。旧オプティマイザーはidentity行列を綴る6つの数値を見て、この演算子は何も変えられないと判断し、削除しました。何も失敗せず、何も警告を記録せず、保存されたページは「Total」を「Invoice」の直後、同じベースラインに描きます。客がPDFを印刷するまで誰も気付かない、まさにその類の欠陥です

なぜ1 0 0 1 0 0 Tmは常にno-opではないのか

identityのTmがno-opになるのは、置き換え先の2つのマトリクスがすでにidentityを保持しているときだけです。これはその手前の演算子の性質であって、自分のオペランドの性質ではありません。ISO 32000-1 §9.4.1はBTがテキストマトリクス(Tm)とテキストラインマトリクス(Tlm)の両方をidentityへ初期化すると述べ、§9.4.2はTmを、連結ではなく両方へ与えられた値を設定するものと定義します。cm(§8.4.4)と比べてください。こちらは現在の変換マトリクスを右から乗算します。identityとの乗算はどんなCTMも変えないので、1 0 0 1 0 0 cmはどこでも削除して安全です。テキストオブジェクトの内側は話が違います。Td、TD、T*そして非identityのTmはTlmを動かし、すべてのテキスト表示演算子(Tj、TJ、'、")は描いたグリフの幅ぶんTmを進めます。そのどれかの後では、identityのTmは原点への本物のリセットです。コンテンツストリームのCTMとテキストマトリクス状態トラッカーでテキスト位置を手で追跡したことがあるなら、状態の連結と置き換えの、あの同じ区別です

PDFlibPasは1 0 0 1 0 0 cmと1 0 0 1 0 0 Tmを別扱いします。cmはCTMを右から乗算するのでどこでもno-opですが、TmはTmとTlmをまるごと置き換え、すべてのTjは描いた幅ぶんTmを進めます。表示済みテキストの後のidentity Tmは本物のリセットです
レポートジェネレーターはそのリセットに頼っていました。identity Tmを削除するとTotalがInvoiceの直後、同じベースラインに描かれ、客のプリンターへ至るまで何も失敗せず、何も記録されず、何も警告しません

後方スキャンがどのidentity Tmを落とすか決める方法

TPDFContentPeepholeOptimizer.RemoveIdentityMatricesは、各identityのTmから後方へ歩き、スキャンがBTか別のidentity Tmに先に到達したときだけ削除します。先行するidentity Tmは、保持されるか、削除予定に入ったばかりかを問いません。どちらにしてもBTと同じく、両方のマトリクスをidentityのままにしたからです。ルールは出会いうるすべての演算子を、次の2つのグループへ分けます:

  • 停止してTmを保持:Td、TD、T*、非identityのTm、Tj、TJ、'、"、ET、パーサーが認識しない任意の演算子、ストリームの先頭
  • 読み飛ばしてスキャン継続:TmやTlmに触れない演算子。たとえばTf、Tc、色セッター、gs、marked-content演算子、そしてcm
PDFlibPasのRemoveIdentityMatricesは各identity Tmから後方へ歩きます。Tf、Tc、色セッター、gs、cmは読み飛ばされ、Td、TD、T*、非identityのTm、Tj、TJ、未知の演算子、ETはスキャンを止めてTmを保持します。BTは削除を保証します
先行するidentity Tmもスキャンを止めます。保持されるにせよ削除予定に進んでいるにせよ、両方のマトリクスをidentityのままにしたからです。どちらにしても、オプティマイザーがグリフを動かすことはありません

保守的なケースは意図的です。未知の演算子は何の可能性もあります。スキャンはその先の推論を拒みます。ETはテキストオブジェクトを閉じるので、その後のTmにはマトリクスの値を保証するBTがありません。スキャンは1度に1つのコンテンツストリームしか扱いません。/Contentsが配列のページでは重要な点です。テキストオブジェクトの途中から始まるレイヤーは、自分のBTを持たないため、先行レイヤーのおかげで冗強になるはずでも、identity Tmを保持します。変なファイルで数バイトの損であり、グリフを動かすことは決してありません。ページテキストを命令レベルで編集するなら、文字とコンテンツバイトのマッピング解説のとおりですが、オプティマイザーが書き換えるのは同じパース済みTPDFContentProgramモデルです

uses
  PDFlibContentModel, PDFlibContentOptimize;

function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
  Prog: TPDFContentProgram;
  Optimizer: TPDFContentPeepholeOptimizer;
begin
  Result := Source;
  Prog := TPDFContentProgram.Create;
  try
    if not Prog.Parse(Source) then
      Exit; // 壊れたストリーム:バイトはそのまま
    Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
    try
      Optimizer.Run; // 削除した命令数を返す
    finally
      Optimizer.Free;
    end;
    Result := Prog.Emit; // 1行に1命令
  finally
    Prog.Free;
  end;
end;

// 削除される:BT直後のTm、連続する2つのidentity Tmの2番目
//   OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// 保持される:Tdの後、Tjの後、非identityのTmの後、BTの外側のTm
//   OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')

冒頭の請求書ストリームでヘルパーを走らせると、identityのTmは生き残ります。後方スキャンがBTに届く前にTjに当たるからです。BTとidentity Tmの間に/F1 12 Tf、2 Tc、0 gを挟んでも、やはり削除されます。どれもテキストマトリクスに触れないからです。BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tmのような並びは、ちょうど1つの演算子を失います。最初のidentity TmがTdの動かしたマトリクスをリセットし、冗長なのは2番目だけだからです

ピーフホールオプティマイザーが実際に走るのはいつか

オプティマイザーは圧縮パスの間だけ、TPDFPageTree.Compressの内側で、まだFlate圧縮されていないコンテンツストリームだけに対して走ります。TPDFlib.SetOptimizeContentStreams(1)がデフォルトで、同じスイッチがTPDFlibSaveOptionsのOptimizeContentStreamsフィールドとして公開されています。CompressContentとCompressPageの両方がこれに従います。/Filterがすでに/FlateDecodeのストリームは丸ごとスキップされます。圧縮済みの既存PDFをロードして再保存しても、演算子は書き直されません。ストリームのパースに失敗した場合は、元のデコード済みバイトがそのまま圧縮されます。TPDFlib.NormalizeContentStreamsは正準な空白と数値でコンテンツをパースして再出力しますが、オプティマイザーは呼びません。ピーフホールルールがどれだけサイズ差に貢献するか見たいときの、有用な基準線になります。フォントサブセット化によるPDFファイルサイズ最適化で扱う、より大きな削減と併せてどうぞ

PDFlibPasはピーフホールオプティマイザーを保存時の圧縮パスの内側だけで走らせます。TPDFPageTree.CompressはSetOptimizeContentStreamsに従い、/FlateDecodeでフィルター済みのストリームは丸ごとスキップされ、パース不能なストリームは元のバイトのまま圧縮され、NormalizeContentStreamsはオプティマイザーをまったく呼びません
圧縮済みストリームのスキップは静かな部分です。既存のPDFをロードして再保存しても、演算子は無傷で出てきます。オプティマイザーが書き直すのは、自分でデコードしたストリームだけだからです
var
  Lib: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    // 非圧縮ストリームはピーフホールルールを通ってからFlateへ
    Lib.SetOptimizeContentStreams(1);
    Lib.CompressContent;
    Lib.SaveToFile('report-optimized.pdf');

    // 同じ選択をバンドルの保存オプションで。Falseでオプトアウト
    Options.CompressContent := True;
    Options.CompressFonts := True;
    Options.CompressImages := True;
    Options.Linearize := False;
    Options.KeepModDate := False;
    Options.OptimizeContentStreams := False;
    Options.GarbageCollect := False;
    Options.PackObjectStreams := True;
    Lib.SaveToFileOptions('report-plain.pdf', Options);
  finally
    Lib.Free;
  end;
end;

旧リグレッションテストが本当に保証していたもの

旧リグレッションテストが保証していたのは、1つの形状だけです。BTの直後のidentity Tmは削除される。Peephole_RemovesIdentityTextMatrixはBT 1 0 0 1 0 0 Tm (hello) Tj ETをオプティマイザーへ与え、Tmが残らないことをアサートします。以前のリリースは、Tlmがidentityでないときのidentity Tmの削除が安全でないとすでに記録しながら、テストがその挙動を「ロック」したため、そのままにしていました。読み直せば、このテストはTdの後や表示済みテキストの後のidentity Tmについて何も述べていません。1つのサンプルのカバレッジをルール全体の契約として扱ったこと自体が、本当の間違いでした。修正はその元のケースを通し続けつつ、削除可能な形状と保持される形状の両方を固定する6つのケースを追加します。テキストオブジェクトの外側のTmや、ETに続くものも含まれます

トレードオフは、書き下してみれば簡単に受け入れられます。すべてのテキストオブジェクトをBT 1 0 0 1 0 0 Tm ...と包むジェネレーターは、今もその冗長な演算子を削除してもらえます。削減のほぼすべてはそこから来ていました。オプティマイザーが諦めるのは、テキストオブジェクトの途中に散発するidentity Tmです。Flateが見る前の1ページ数バイト。引き換えに得るのは、モジュールヘッダーがはっきり述べる保証です。すべての変換は出力等価で、見えているページを決して変えない。テキストを動かすサイズオプティマイザーはオプティマイザーではなく、圧縮率の良いレンダリングバグです

ここで述べたコンテンツストリームパーサー、ピーフホールオプティマイザー、保存時の圧縮オプションは、いずれもPDF Library for Delphi and C++Builderに同梱されます。NormalizeContentStreams、CompressContent、TPDFlibSaveOptionsも公開されており、ドキュメントごとの書き出し方を調整できます