DelphiおよびC++Builder向けネイティブVCL PDFコンポーネントライブラリであるPDFlibPasは、レンダリングキャンバスに一切触れることなく、そのTPDFContentStateTrackerクラスを通じてページのコンテンツストリームを再生する。トラッカーに一度に1つの解析済み演算子を供給し続けることで、実行中のグラフィックステート記録——現在の変換行列、テキスト行列、クリップ境界、q/Qの保存スタック——を、各演算子が実行される前後にスナップショット可能な状態に保つ
一連のテキストが実際に印刷ページのどこに着地するかを尋ねてみると、コンテンツストリームの生の数値だけでは毎回あなたを誤らせるだろう。TPDFContentProgram.GetTextRunsは、すでにTPDFTextRunのOriginXとOriginYフィールドを通じて各テキスト表示命令のアンカーポイントを報告しており、フィールドコメントは、このポイントがすでにTm、Td、TD、T*を通じて折り込まれたテキスト空間に存在することを明記している。まだ欠けているもの、そしてそれらのコメントが呼び出し元が供給しなければならないと述べているものは、まさにその命令でアクティブなCTMである——ストリーム内でそのポイントまでに開いているいくつのq/Qペアの内側にネストされていようと、そこまでに連結されたすべてのcmの積である
なぜレンダリングするのではなくコンテンツストリームを再生するのか
PDFlibPasは、2つの別個の仕事のために2つの別個のグラフィックステートの概念を保持しており、この分割は意図的である。レンダラーの内部状態記録は、ライブなデバイスキャンバスハンドル、クリッピング領域ハンドル、フォントラスタライズキャッシュを運ぶ——現在描画されているどのサーフェスにも結びついた実際のリソースであり、そのサーフェスがなくなれば意味をなさない。TPDFContentGraphicsStateはそれを何一つ運ばない:それは、ISO 32000-1 §8.4がコンテンツストリーム演算子だけから到達可能と定義する値——CTM、線のスタイル、色、テキスト状態、そして導出されるクリップとパスの境界——に限定された単純なレコードである。このレコードはキャンバス参照も開いているファイルハンドルも保持していないため、呼び出し元はコンテンツストリームを解析し、TPDFContentStateTrackerでそれを歩き、そのバイトを生成したものがなくなってしまった後もずっと、結果として得られるスナップショットを使い続けることができる
TPDFContentStateTrackerはどのようにCTMを構築するのか
TPDFContentStateTracker.Applyは、cm演算子の6つのオペランドを、PDF自体が指定するのと同じ前乗算を使ってトラッカーのCTMに連結する:新しい行列M2は、P′ = P × Mとして点が変換される行ベクトル規約(ISO 32000-1 §8.4)で、M2 × CTMとして現在のCTMと結合する。間違えやすい部分は線形部分ではなく平行移動の項にある:M2自身の平行移動は、現在のCTMの平行移動が上に加算される前に、現在のCTMの回転・拡縮成分を通過しなければならない。そのステップを飛ばして代わりに素朴な成分ごとの結合をハードコードすると、あなたが最初にテストする単独のcmは正しく見えるが、2つ目や3つ目のネストしたcmの下流にあるすべての座標が静かにずれてしまう。これはまさに、それを捕まえるはずの単体テストが失敗するには少なくとも2つの連鎖した変換を必要とするため、コードレビューを生き延びてしまう類のバグである
var
Prog: TPDFContentProgram;
Runs: TPDFTextRunArray;
States: TPDFContentGraphicsStateArray;
DeviceX, DeviceY: Double;
I: Integer;
begin
Prog := TPDFContentProgram.Create;
try
Prog.Parse(ContentBytes);
Runs := Prog.GetTextRuns;
// One before-instruction snapshot per operator, computed in a single pass
States := Prog.TraceGraphicsStates(nil, False);
for I := 0 to High(Runs) do
begin
// OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
// at this instruction is still missing (ISO 32000-1 8.4)
with States[Runs[I].InstructionIndex].CTM do
begin
DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
end;
LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
end;
finally
Prog.Free;
end;
end;
上記のループは冒頭の悩みどころに答える:TPDFContentProgram.GetTextRunsは、すでにTm、Td、TD、T*を通じて折り込まれたOriginXとOriginYを返し、TraceGraphicsStates(nil, False)は残る1つのピース、すなわち各ランがキャプチャされた正確なインデックスにおける命令前のCTMを、プログラム全体に対する単一の線形パスで供給する。nilを渡すことで、そのメソッドはその呼び出し専用のプライベートなトラッカーを保有し内部でそれを解放できる。これは一回限りのスキャンにとって正しい選択である;既存のTPDFContentStateTrackerインスタンスを渡すことは、代わりに、複数のコンテンツストリームから組み立てられたページ全体で状態を連続させ続ける方法である。なぜならISO 32000-1はページの/Contents配列を1つの論理的なストリームとして扱い、q/Qのスタックはそれに一致しなければならないからだ
テキスト行列はQを生き延びるが、グラフィックステートはそうではない
ISO 32000-1 §9.4.2は、Td、TD、Tm、T*をBT/ETブロックの内部でテキスト行列とテキスト行行列を構築する演算子として定義しており、PDFlibPasはその区別を鮮明に保つ:TdとTDはテキスト行行列に純粋な平行移動を連結し、T*は現在のleadingの負の値を使って同じことを行い、Tmだけが与えられた6つの数値で両方の行列を完全に置き換える。BTはテキストオブジェクトの開始時に、正確に一度だけ両方の行列を単位行列にリセットする——しかしqとQはそれらに全く触れない。TPDFContentStateTracker.Applyは、まさにこの理由でcoRestoreStateを特別扱いする:保存された状態をスタックからポップする前に、現在のテキスト行列、テキスト行行列、BT/ETフラグをキャプチャし、ポップされた状態がたまたま保持していたものの上にそれらを再適用する。なぜなら、テキストランを包むq/Qペアは、テキストの位置を後戻りさせるべきではないからだ
var
Tracker: TPDFContentStateTracker;
Prog: TPDFContentProgram;
I: Integer;
begin
Prog := TPDFContentProgram.Create;
Tracker := TPDFContentStateTracker.Create;
try
Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
for I := 0 to Prog.Count - 1 do
begin
Tracker.Apply(Prog[I]);
if Prog[I].Op in [coShowText, coRestoreState] then
LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
end;
finally
Tracker.Free;
Prog.Free;
end;
end;
この一連の処理を実行すると、2つ目のTjで報告されるCTMは、qの前に持っていた単位スケールに戻っている——保存・復元ペアの内側にある2 0 0 2 0 0 cmは、q/Qが要求する通りに消えている。しかし、その同じ命令におけるTextMatrix.DXは、それでも100のままである:それを設定したTdはqの前に実行されたため、それはQが触れる権利を一度も持ったことのないグラフィックステートではない。そうではないと仮定したツールは、2つ目のグリフランがページ上の間違った水平位置から始まっていると報告してしまうだろう
クリッピングパス演算子が実行されるとどうなるのか
WまたはW*演算子はクリップを即座に縮小しない;それはどの塗りつぶし規則を使うかを記録するだけであり、実際の交差はそれに続くどのパス描画演算子か——PDFの作成者が何も描画せずにクリップするためだけに日常的に使うno-opの描画子nを含む——を待つ。TPDFContentStateTrackerはその2段階のタイミングを正確に反映する:coClipとcoClipEvenOddは保留中のクリップ規則フラグを設定するだけであり、すべてのパス描画演算子によって呼び出されるEndCurrentPathこそが、保留中のパスの境界をClipMinX、ClipMinY、ClipMaxX、ClipMaxYに実際に交差させる。この段階付けを正しくすることは、前後スナップショットの契約自体にとって重要である:W命令のちょうどそこで取られた前スナップショットは、それでも古い、より広いクリップを示さなければならない。なぜなら、ストリームのその時点ではクリップはまだ発効していないからだ。そしてこの2つのステップを1つに潰してしまうと、前状態がその名の通りの意味を持つことに頼っているすべての呼び出し元を静かに壊してしまうだろう
ClipBoundsExactは、呼び出し元が2つの状況のどちらを見ているかを伝える。それがTrueになるのは、それ以外は空のパス上でreによって構築された単一の軸並行矩形の場合だけである——これはPDFlibPasが正確に4つの数値として表現できる唯一の形状である。それ以外のすべて——回転した矩形、曲線の輪郭、複数のサブパスを持つ複合パス、あるいはテキストレンダリングモードから構築されたクリップ——は依然としてClipMinXからClipMaxYを生成するが、ClipBoundsExactはFalseにクリアされる。これは、その4つの数値が安全な外側の境界であって真のクリップ形状ではないという正直な信号である;その境界だけを必要とする呼び出し元——たとえばPDFページを1ビットモノクロにレンダリングするで説明されているGDIハーフトーンのダウン変換の前に矩形の部分領域を分離する場合——は、ページの幾何学から再導出するのではなく直接それを読める
ベジエ曲線:正確な境界か安全な境界か
3次ベジエセグメントを境界付ける最も安価な方法は、その4つの制御点の凸包を取ることであり、それは曲線がそこから決して外に出ないため常に安全である——しかし浅く幅の広い曲線は、曲線が実際に占める範囲よりはるかに大きなバウンディングボックスを報告することがあり、これはまさに最も重要な、大きな装飾的パスにおいて、クリップベースのフィルタリングを弱めてしまう。PDFlibPasは代わりにより厳密な問題を解く:各軸について、開区間(0, 1)の内部にある根について三次曲線の導関数を解き、見つかった根と両端点でその曲線を評価する。これは、過大評価ではなく曲線の真の軸並行の範囲を得るための標準的な閉形式の方法である。しかし曲線ごとの精度はクリップ自体には引き継がれない:曲線の輪郭がクリッピングパスになると、ClipBoundsExactはそれでもFalseに落ちる。なぜなら、どれだけ厳密であってもバウンディングボックスは、それが境界付ける曲線と同じ形状ではないからだ。そして状態トラッカーは、実際には曲線があるところに矩形があると呼び出し元に仮定させるよりは、そう述べる方を選ぶ
各演算子の前後の状態を読む
呼び出し元が前状態を望むか後状態を望むかは、完全にその演算子が何をするかに依存する:パスやテキストランに関する描画やヒットテストの質問は、その演算子が実際にどう描画したかを決定づけたものであるため、その演算子が実行される直前の状態を望む。一方、gsのような状態設定演算子に関する診断的な質問は、通常それがたった今何を変えたかを見たがる。TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction)は、その選択をまさに単一のブール値として公開し、どちらの瞬間が要求されようとも、プログラム全体に対する1回の線形パスで命令ごとに1つのTPDFContentGraphicsStateを計算する。GetGraphicsState(InstructionIndex, AfterInstruction, State)は、プログラム全体ではなく単一の命令について同じ前後の選択を提供するが、そこに到達するために呼び出しのたびに命令ゼロから再生する。そのため、ループの中でそれを呼んで多くのインデックスをスキャンすることは、同じプログラムに対する単一のO(n)のTraceGraphicsStates呼び出しに対してO(n²)のコストがかかる
var
Before, After: TPDFContentGraphicsState;
begin
// Same instruction index, two different instants: before vs. after it runs
Prog.GetGraphicsState(CmIndex, False, Before);
Prog.GetGraphicsState(CmIndex, True, After);
// Before.CTM reflects every earlier cm; After.CTM already folds in
// this instruction's own concatenation as well
end;
不正なコンテンツストリームと付き合う
実際のPDF生成器では十分によくある2種類の不正な入力があり、TPDFContentStateTrackerはそれらで失敗するのではなくそれらを許容しなければならない。1つ目はq/Qの境界にまたがるパスである:現在のパス、現在の点、サブパス数はグラフィックステートのパラメータではない——ISO 32000-1 §8.4はqとQが保存・復元するものを扱っており、構築中の現在のパスはその中に含まれない——そのためTPDFContentStateTrackerはそのデータを保存された状態の外側で完全に追跡し、qの前に開始されたサブパスは、対応するQの直後でも未描画のまま依然としてそこにある。2つ目は、ストリーム内でそれ以前にどこにも対応するqを持たない裸のQである。コンテンツストリームの断片を連結して組み立て、記帳を間違えるジェネレータの出力では珍しくない。TPDFContentStateTracker.RestoreUnderflowCountは、例外を発生させたり状態を破損させたりする代わりに、そうしたイベントのすべてを数える:対応しないQは、あたかもその命令がno-opだったかのように、現在のグラフィックステートを正確にそのままにするだけである。そのためストリームの残りは正常な状態で再生を続け、呼び出し元は事後にその件数から、その入力を生成した誰かにフラグを立てて戻す価値があるかどうかをそれでも決められる
CTMの合成、q/Qからのテキスト行列の独立性、そしてクリッピングパスの段階的な実現は、コンテンツストリームが実際に描画されるかどうか、あるいはどう描画されるかに一切依存しない。これがまさにポイントである:同じTPDFContentStateTrackerのスナップショットは、そのページが一切レンダリングされない場合でも、あるいはPDFlibPasがそのファイルに選んだどのバックエンドに渡されようとしている場合でも正しい。これにはPDFlibPasにおけるマルチエンジンPDFレンダリングのガイドで扱われている実行時エンジン切り替えも含まれる。コンテンツ分析、座標マッピング、編集除去ツールはすべて、レンダラーに関与を求めるずっと前に、あるいは全く求めることなく、完全にトラッカーの出力の上で動作できる
TPDFContentStateTrackerを通じたコンテンツストリームの再生は、DelphiおよびC++Builder向けネイティブVCL PDFコンポーネントライブラリであるPDFlibPasに組み込まれた構造化コンテンツ編集フレームワークの一部である