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とテキストマトリクス状態トラッカーでテキスト位置を手で追跡したことがあるなら、状態の連結と置き換えの、あの同じ区別です
後方スキャンがどの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
保守的なケースは意図的です。未知の演算子は何の可能性もあります。スキャンはその先の推論を拒みます。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ファイルサイズ最適化で扱う、より大きな削減と併せてどうぞ
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も公開されており、ドキュメントごとの書き出し方を調整できます