PDFlibPasは、グローバルな訪問済み集合ではなくアクティブな呼び出しチェーンを追跡することで、DelphiのPDFコンテンツストリーム内の再帰的なForm XObject呼び出しを解決する。そのためTPDFlib.EnumPageContentStatesExは、1ページで何度も呼び出される同じFormを、正当な再利用を循環と誤認することなく歩くことができる。請求書テンプレート内のスタンプFormXObjectが典型的なケースである:同じオブジェクトが1ページ上のヘッダー、フッター、透かしレイヤーから呼ばれ、それ自体にループバックする呼び出しチェーンだけが本物の循環である
ISO 32000-1 §8.10は、Form XObjectを、ページや別のFormがDo演算子で呼び出す自己完結的なコンテンツストリームとして定義しており、それは/Matrix内の独自の座標系、その座標系における/BBox内のクリッピング境界、そして任意で独自のリソース辞書を完備している。この仕様には、1つのFormが何回呼び出せるか、あるいはFormが互いにどれだけ深く呼び出しあえるかについての上限は何もない。そのため準拠したパーサーは、正当な再利用と正当なネストの両方を受け入れつつ、それでも仕様が実際に禁止している1つの配置——直接的にせよ推移的にせよ自分自身を呼び出すForm——に対して自身を守らなければならない。PDFlibPasは、あらゆるDoスナップショットに付随するTPDFlibContentFormTraversalStatus値を通じてその区別を報告する。最も注目すべきは、成功した降下を表すftsEnumeratedと、実際にループである唯一のケースを表すftsCycleである
なぜ同じForm XObjectの再利用は誤った循環を引き起こさないのか
繰り返されるForm XObjectの参照は、それ自体では何も間違っていることの証拠ではない。ISO 32000-1は、同じFormオブジェクトが作成者が望むだけコンテンツストリーム内の多くの場所から呼び出されることを許しており、これはまさにロゴのスタンプ、レターヘッドのテンプレート、あるいはページ番号のフッターが、そのコンテンツストリームを何度も複製することなくページ全体で再利用される方法である。暴走する再帰に対する素朴な防御策は、オブジェクト番号をキーとする単一の訪問済み集合である:ウォーカーが最初にFormオブジェクト12を見たとき、12を見たものとしてマークし、木構造の他のどこであれ二度とそれに入ることを拒否する。このアプローチは、同じスタンプが1ページ内の無関係な2つの隅に現れた瞬間に破綻する。なぜなら、2つ目の完全に正当な呼び出しが到着するのは、そのオブジェクト番号がすでに「見た」とマークされた後であり、あたかもループであるかのように拒否されてしまうからだ
PDFlibPasは、循環検出の範囲を文書全体ではなく現在の呼び出しチェーンに限定することでこの誤検出を回避する。EnumPageContentStatesExは、解決されたFormストリームをその中へ降りる直前にアクティブな呼び出しチェーンにプッシュし、成功したか否かにかかわらずその降下が戻った瞬間にその同じエントリを再びポップする。同一ストリームの兄弟呼び出しは、最初のものがすでにポップされた後にだけ始まるため、兄弟呼び出しがそれをチェックする時点までに呼び出しチェーンはそのストリームから解放されており、ウォーカーは他のあらゆるFormと全く同じようにそれを列挙する。本物の循環はその同じチェーン上で異なって見える:Form AがForm Bを呼び、Bはそれ自身のコンテンツがAに呼び戻すときにチェーン上でまだ開いており、Aはまだ戻っていない外側の呼び出しからチェーン上に座り続けている——それがまさにftsCycleが報告する唯一の形であり、現在の呼び出しチェーンのどこか前にまだ開いているFormストリームであって、単にページの他のどこかに存在するだけではない
PDFlibPasはForm XObjectの再帰をどこまで深くしてから止めるのか
循環検出と深さ制限は2つの異なる問題を解決しており、PDFlibPasはまさにその理由でそれらを2つの異なるTPDFlibContentFormTraversalStatusの結果として保持している。20個の別個のFormの連鎖で、それぞれが次を呼び、どれも繰り返さないものは、どんな定義によっても循環ではない——アクティブチェーンのチェックは繰り返されたストリームを一切見つけない——しかし20の正直なネストのレベルは、それでも他に何も止めなければ不正なあるいは敵対的なPDFが任意に押し上げることのできる20レベルの解析、行列の連結、リソースの解決である。EnumPageContentStatesExはまさにこの理由でMaxFormDepthパラメータを取り、呼び出し元が何を求めようとも、渡された値を最大64にクランプする。深さゼロは、それ自体で知っておく価値のある特殊なケースである:それはForm再帰を完全に無効化し、より古いEnumPageContentStatesメソッドの平坦なページのみの挙動を再現する。これが、そのモードにおけるすべてのDoスナップショットが、何も試みることなくftsNotRequestedを報告する理由である
var
Lib: TPDFlib;
States: array of TPDFlibContentGraphicsState;
Count, I: Integer;
begin
Lib:= TPDFlib.Create;
try
if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
Exit;
Lib.SelectPage(1);
Count:= Lib.EnumPageContentStatesEx(True, 8, States); // count only
SetLength(States, Count);
Lib.EnumPageContentStatesEx(True, 8, States); // fill
for I:= 0 to Count- 1 do
if States[I].FormTraversalStatus= ftsCycle then
LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
finally
Lib.Free;
end;
end;
呼び出しごとに1つのサブトラッカー:グラフィックステートの分離
Form XObjectへの降下は、すでにページを歩いているものを共有するのではなく、それぞれ独自のグラフィックステートトラッカーを得る。なぜならFormのコンテンツストリームは、それが見つけたのと正確に同じ状態にグラフィックステートを残すことが求められているが、PDFlibPasは開いているすべてのPDFがその要件を実際に守っているとは仮定できないからだ。子トラッカーは、呼び出し元のDo命令でアクティブだったCTM、色の状態、テキストパラメータのスナップショットから始まり、その後、そのFormの1つの命令を実行する前に、自身の保存・復元スタックと現在のパスの追跡を空にリセットする。不注意な、あるいは損傷したFormの内部にある、対応するQを持たない不釣り合いなqは(古いツールで生成されたPDFに見つかるのが稀ではない)、その1回の呼び出しのトラッカーの内側に閉じ込められたままであり、ページのトラッカーにも、コンテンツストリーム内でそれより1行後に座っている同じスタンプの兄弟呼び出しにも決して漏れない
Formの/Matrixは、Do時点で有効なCTMと、cm演算子と同じ方法で合成される。それに置き換わるのではなく現在の変換に左から乗算される。PDFlibPasは意図的にその1つのコードパスを再利用し、2つ目の式を維持することはしない。なぜなら同じ行列代数の2つの独立した実装は、拡縮・回転・せん断の合成を数回経ると静かにずれていく類の重複そのものだからだ。次に/BBoxは、行列がすでに適用された後、Form自身の座標空間でクリップする。そしてそのボックスの4つの角すべてが個別に変換される、対角の2角だけではなく。なぜなら、回転またはせん断されたFormは、そうしないと、変換がそれをどこか別の場所に動かす前は極端な角だった場所に座る実際のコンテンツを見逃すバウンディングボックスを報告することがあるからだ。前の例のループを同じStates配列にわたって拡張すると、これらのフィールドを直接読める
for I:= 0 to Count- 1 do
if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
States[I].FormBBoxKnown then
Writeln('Form ', States[I].XObjectResource, ' matrix ',
States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);
同じリソース名を持つ2つのFormは1つのフォントを共有するのか
いいえ。/F1のようなリソース名は、それが使われる時点でアクティブなリソース辞書に対して相対的にのみ何かを意味する。そして2つの異なるForm XObjectは、その同一の名前のもとで2つの完全に異なるフォントを自由に定義できる。PDFlibPasは、すべてのリソース名と一緒にリソーススコープを追跡することでこれを解決する:あるFormが独自の/Resources辞書を運んでいる場合、その辞書はその内部のすべてに対する完全なリソーススコープになり、Form自身の辞書がたまたま省略しているものについて、ページや呼び出し元の辞書へのキー単位のフォールバックは一切ない。/Resourcesキーを全く持たないFormだけが(これは一部の古いPDF生成器が今も生成するパターンだ)、呼び出し元の辞書を丸ごと継承する。そしてそれは意図的な互換性のための例外であり、新しい出力で頼るべき一般的なルールではない。したがってTPDFlibContentGraphicsStateスナップショットにおけるフォントの同一性は、名前だけではなくFontResourceとFontResourceScopeのペアであり、特定の/F1がそのスコープの中で正確にどの間接オブジェクトに解決されたかを確認するためにFontObjectNumberが利用できる
同じスコーピングは、Formが運びうる他のすべての名前付きリソース、ExtGStateエントリやネストしたXObjectエントリを含めて適用される。なぜなら基礎となる解決の仕組みはフォントを特別扱いしないからだ——フォントのケースがたまたま最も重要なのは、フォントの同一性の不一致が明白な失敗ではなく静かに間違ったグリフを生成するからにすぎない。フォント名だけでテキストランをグループ化し、リソーススコープでもグループ化しない抽出コードは、たまたま名前を共有する2つの視覚的に異なるフォントをマージしてしまい、その間違いは、誰かが一貫した1つのフォントとして読まれるはずのものの内部に、間違った書体からの数字が座っていることに気づくまで表面化しない
for I:= 0 to Count- 1 do
if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
States[I].FontObjectNumber, States[I].ContentDepth);
自分のパイプラインでFormTraversalStatusを読む
FormTraversalStatusは、あらゆるDoスナップショットをそれ自体で小さな診断レポートに変える。そしてそれを無視するパイプラインは、まさに不完全な抽出を説明してくれたはずの情報を捨てている。ftsNotApplicableは、その命令がそもそも解決されたForm呼び出しでは一度もなかったことを意味する;ftsNotRequestedは、この呼び出しについて再帰がオフにされていたことを意味する;ftsEnumeratedは、Formが正常に解析され歩かれたことを意味する;ftsDepthLimitとftsCycleは、意図的に降下が早めに打ち切られる2つの方法をマークする;そしてftsMalformedは、その歩みを止めた他のすべて——解決できないストリーム参照、解析に失敗した/Matrixや/BBox、あるいはForm自身のコンテンツを実行中に発生した例外——をカバーする。この最後のケースは運用上重要である。なぜなら、失敗したネストされた歩みは、その枝についてすでに生成していた部分的な出力を何であれロールバックするからだ。そのため呼び出し元は、あるFormが本当に空だったのか、あるいはそのコンテンツストリームに2命令入ったところで単に爆発しただけなのかを推測する必要が一切ない
var
Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
Status: TPDFlibContentFormTraversalStatus;
begin
for Status:= Low(Tally) to High(Tally) do
Tally[Status]:= 0;
for I:= 0 to Count- 1 do
Inc(Tally[States[I].FormTraversalStatus]);
if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;
境界、コスト、そしてこれがどこに収まるか
Formのコンテンツストリームは、そのFormが何回呼び出されようとも、列挙呼び出しごとに正確に一度だけデコードされ解析される。なぜならPDFlibPasは、毎回の兄弟呼び出しでそれを再解析するのではなく、解析済みの命令リストを基礎となるストリームオブジェクトに対してキャッシュするからだ——冒頭の例にある三隅のスタンプは一度デコードされ三度歩かれるのであり、三度デコードされるのではない。すべての単一の呼び出しで実際に再構築されるのは、ある呼び出しサイトと次の呼び出しサイトの間で正当に異なるすべてのもの:子トラッカー、連結されたCTM、交差したクリップ、リソーススコープである。この呼び出しごとのCTMとクリップの記帳は、PDFlibPasのコンテンツストリームCTM・クリッピング状態トラッカーの背後にあるのと同じ仕組みであり、Form再帰自体を超えるあらゆるコンテンツストリームの歩みにとって、本稿と併せて読む価値がある
このAPIをより大きなパイプラインに組み込む前に期待値を設定しておく価値のある2つの限界がある。64レベルの深さの上限は、正当に深い文書のためのチューニングつまみではない、なぜなら実際の請求書、明細書、レポートテンプレートは本質的にFormを3、4レベルより深くネストすることは決してないからだ——実際にftsDepthLimitにヒットする文書は、異常に凝っているというよりはるかに、不正であるか敵対的である可能性が高く、より大きな数値で静かにリトライするのではなく、データ品質の信号としてログに記録する価値がある。EnumPageContentStatesExはまた読み取り側の分析APIでもある:それは、あるコンテンツストリームが何をするかを報告するのであり、あるFormがそもそも表示されるべきかどうかは報告しない。それは、ビューアがオフに切り替えたかもしれないレイヤーの背後にスタンプや透かしのFormが座っているとき、Optional Content Groupの可視性状態によって答えられる別個の問題である。呼び出しチェーンによる循環検出、呼び出しごとの分離、リソーススコーピングは、一緒になって、DelphiおよびC++Builder向けPDFlibPasコンポーネントにおけるコンテンツストリーム検査面の一角を構成する