何も描かないPDFレンダラーには、たいてい描画コードそのものにバグは存在しない。DelphiとC++Builder向けのHotPDF Componentでは、すべてのログ行が綺麗なままページが白紙で描画されるという4つの別個の不具合があった:先頭にスラッシュを持つ名前オペランド、逆転したcm連結、そしてゼロを読み取ってしまうトークンインデックスである。どれも例外を投げなかった。どれもログに残らなかった。コンテンツストリームは正しくトークン化され、演算子ディスパッチャはすべての演算子を認識し、画像XObjectは有効なビットマップへとデコードされ、それでいてページは空のまま出てきた。この組み合わせ——あらゆる段階で成功を報告しながら何も目に見えるものを生成しないパイプライン——は、失敗するのではなく静かに外れるルックアップやインデックスの徴候である。これはそうした一群の不具合についての事後検証であり、そしてそれを38回のリリースにわたって生き延びさせた、テストの規律についての検証でもある
なぜPDFレンダラーはまったく何も描かないことがあるのか
PDFレンダラーにおけるリソースルックアップの失敗は、空白のページと見分けがつかないからだ。コンテンツストリームの名前オペランドとリソース辞書のキーは2つの異なる文字列空間であり、HotPDFは正規化せずにこの両者を比較していた。トークナイザーは/Im0を読み取りソリダスを保持する。それがそのトークンそのものだからだ。一方、読み込まれた/Resources /XObject辞書はキーをIm0として保存する。パーサーが辞書キーを構築する際に区切り文字を取り除くからだ。そのためオペランド名に対するあらゆるFindValueは-1を返していた。この被害範囲は画像よりも広かった。ISO 32000-1 §8.9はDoを、§8.4はgsとその/ExtGStateルックアップを、§8.6はcsとCSを、§8.7.4.3はshを扱う。これら5つの演算子はすべて生のオペランドでリソースのサブ辞書をキーにしており、したがって5つすべてが外れていた。名前付き色空間はDeviceGrayへとフォールバックし、それが1 scnを白いページの上の白いインクに変えていた。画像XObjectはそもそも一度も描画されなかった——実質的に、ビットマップ画像の経路は導入された日から一度も機能していなかった。修正は、オペランドをキーとするすべてのルックアップに適用されるユニットレベルのヘルパーであり、これがこの規約を二度とずれさせないための唯一の方法である
// Page content stream, the ordinary image-placement idiom:
// q
// /GS0 gs
// 200 0 0 120 60 400 cm
// /Im0 Do
// Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.
function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
Result := N;
if (Result <> '') and (Result[1] = '/') then
Delete(Result, 1, 1);
end;
// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
Exit;
もう1つ関連する見落としが1層下にあった。レンダラーはストリームと辞書に対してだけ型付きリゾルバーを持っていたため、トップレベルの配列オブジェクトを指す間接参照——他方の端に[/Separation ...]を持つよくある/CS0 5 0 R——はどちらでもnilに解決され、未解決のリンクへとフォールバックしていた。汎用のオブジェクトリゾルバーを追加することで、名前付き色空間と関数配列の両方が一度に直った。シェーディング辞書を組んでいるなら、/Functionエントリが間接参照であることが非常に多い軸方向・放射状シェーディングの経路にも同じ解決の規律が当てはまる
cm演算子と逆向きに書かれた連結
2つ目の不具合は画像をページからおよそ10万ピクセルもずらして配置しており、これはまさに画像を描いていないように見える。ISO 32000-1 §8.3.4は行ベクトルでPDFの変換を定義しており、cm演算子はそのオペランド行列Mを現在の変換行列にM × CTMとして連結する——Mが先に効き、既存のCTMがその後に効く。HotPDFはHPDFMatMul(A, B)を通じて行列を合成しており、これはBをAより先に適用する。したがって正しい呼び出しは古いCTMをAとして渡さなければならない。出荷されたコードはオペランド行列をAとして渡しており、CTM × Mを生成していた
順序の逆転は単独のcm1つに対しては無害だが、標準的な2段階の慣用句に対しては破滅的である。1 0 0 1 x y cmに続けてw 0 0 h 0 0 cmで画像を配置すると、正しいカスケードは単位正方形を(w, h)でスケールしたあと(x, y)に平行移動する。逆転したカスケードのもとでは平行移動が先に入り、スケールがそれを掛け算してしまうため、名目上は(60, 400)に配置され200×120にスケールされるはずの画像が(12000, 48000)に着地してしまう。ブリット処理の先頭にあるクリップテストがそれを弾き、ブリットはスキップされ、どこにも問題を報告するものはない
// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.
// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)), GS.CTM);
// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
NumAt(3), NumAt(2), NumAt(1)));
これを示唆に富むものにしているのは、同じソースファイルの中にすでに正しい順序が存在していたという事実だ。Form XObjectの/Matrixエントリも同じ逆転した合成を持っていたが、Type 3グリフの経路と埋め込みグリフのアウトライン経路はどちらも最初から正しかった。グリフの配置は反転させると目に見えて原点に潰れてしまうため、誰かがすでに修正を強いられていたのである。2つの流儀が1つのユニットの中で三十数回のリリースにわたって共存し、それぞれが自分の関数の中では正しく、どのレビュアーも気づかなかった。どちらの呼び出し箇所も単独で見れば間違っているようには見えなかったからだ
トークンインデックスが1つずれるとどうなるか
構文的には処理されているのに意味的には死んでいる演算子が12個生まれる。レンダラー内のオペランドアクセサはNumAt(Back)であり、これはTokens[OpIndex - Back]を読み取る。OpIndexは演算子トークン自体のインデックスだ。したがって単一オペランドの演算子はback 1の位置にその数値を見つけるはずである。そのうち12個はNumAt(0)として書かれており、これは演算子トークンを読み取り、ctOperandNumberという種類チェックに失敗し、デフォルトのゼロを返す。その一覧は、ISO 32000-1 §9.3のテキスト状態演算子であるTc、Tw、Tz、TL、Ts、Tr、そして§8.4.3のグラフィックス状態演算子であるw、J、j、M、ri、iである。文字間隔と単語間隔は何もしなくなり、水平スケーリングは決して適用されず、行送りはゼロのままなのでT*は決して行を進めず、テキストのライズは何もせず、レンダーモードは常に塗りつぶしとなり、どの文書のどのストロークも、宣言された線幅にかかわらず1ピクセルの細い線として出てきていた。m、rg、Tmのような複数オペランドの演算子はNumAt(1..6)を使っておりすべて正しかったため、この関数を眺めるレビュアーには、その中に12個の誤ったエントリが埋め込まれた、もっともらしいインデックス演算の壁が見えていたことになる
function NumAt(Back: Integer): Double;
begin
Result := 0;
if (OpIndex - Back >= 0)
and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
Result := Tokens[OpIndex - Back].NumValue;
end;
// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1) // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading := NumAt(1) // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w' then GS.LineWidth := NumAt(1) // previously NumAt(0)
なぜテストスイートは38バージョンもの間ずっと緑だったのか
アサーションが弱すぎて、描画されたページと部分的にしか描画されなかったページを区別できなかったからだ。レンダリングのスモークテストは、出力ビットマップが完全な黒ではない、ページが空白ではない、画像のダイジェストがゼロではない、といったことをアサートしていた。これらのすべては、テキストは描画され画像は描画されないときにも成り立ってしまう。テキストは問題なく描かれていたため、フレームバッファは決して一様にはならず、ダイジェストは決してゼロにはならず、実質的に画像パイプライン全体が死んだコードであったにもかかわらずスイートは成功を報告していた。弱いアサーションはグラフィックスに対してこそ誘惑的である。強いアサーションはまさに脆く見えるからだ。アンチエイリアシングの端が1ピクセルずれただけで壊れるテストを望む者は誰もいない。そのため自然な後退は、どんな妥当な変更も破らないような何かをアサートすることになる——そしてその後退は、どんな不合理な変更もやはり破ることのできない述語へとあなたを着地させてしまう。あるセパレーション色空間のテストは、出力が黒と区別できることだけをアサートしていた。白地に灰色はそれを通過し、白地に白もそれを通過した。このテストは正しい色が塗られたかどうかを測っていなかった。キャンバスの上で何かが起きたかどうかだけを測っていたのである
実際に失敗するレンダリングのアサーションをどう書くか
期待される色のピクセルを、期待される量だけ数え、位置とサイズはそのカウントから自然に導かれるようにするとよい。この置き換えの規律は、手作りの最小限のPDFを1ファイルにつき1つの視覚的事実として用意し、特定のRGBトリプレットの許容誤差内に収まるピクセル数についてアサートすることである。既知のオフセットに配置された200×120の純粋な赤の画像は、およそ24000個の赤いピクセルを生成しなければならない。リソースルックアップが外れればカウントは0になる。cmのカスケードが逆転していればカウントは0になる。画像が間違った色空間で描画されればカウントは0になる。1つの数値がこの3つすべてを捕まえ、その許容帯は、そもそも正確な比較を人々にためらわせていたアンチエイリアシングのノイズを吸収してくれる
function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
X, Y: Integer;
C: TColor;
begin
Result := 0;
for Y := 0 to Bmp.Height - 1 do
for X := 0 to Bmp.Width - 1 do
begin
C := Bmp.Canvas.Pixels[X, Y];
if (Abs(GetRValue(C) - R) <= Tol)
and (Abs(GetGValue(C) - G) <= Tol)
and (Abs(GetBValue(C) - B) <= Tol) then
Inc(Result);
end;
end;
// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
'image XObject was never drawn');
4つのスモークテストがこの方式で書き直された——Type 4のtint変換、画像のDo配置、オプショナルコンテンツの可視性ケース、そしてTrのストロークモードである——そしてこれらを合わせるとその一群全体があぶり出された。それが本当の教訓であり、このコードベースを超えて一般化できる:レンダリングパイプラインにおいて、アサーションは色を名指ししなければならない。それより緩い何かは、レンダラーが動いたかどうかのチェックであって、それが描いたかどうかのチェックではない。もし自分でページからビットマップへのテストハーネスを組んでいるなら、ページラスタライズの解説記事が、最初の回帰テストにピクセルカウントのヘルパーを組み込む自然な出発点になる
正直な境界
はっきり述べておく価値のある2つの限界がある。テキストクリッピングのレンダーモード4から7は、その基本の塗りつぶしまたはストロークモードとして描画される。レンダラーがグリフのアウトラインから蓄積されたクリップパスをモデル化していないからだ。テキスト形のクリッピングに依存する文書は、その下にあるクリップされたアートワークではなくテキストそのものが描画されることになる。そしてここで説明したピクセルカウントの規律はスモークテストの技法であって、適合性スイートではない——それが証明するのは、特定の視覚的事実がフレームバッファに到達したということであり、これは出力が参照ラスタライザと一致することを証明するよりもずっと低いハードルである。しかしそれこそが、この4つのバグが3年間のリリースにわたってクリアできなかったハードルなのだ
ここで論じたレンダラーは、DelphiとC++Builder向けの標準HotPDF Componentの一部として提供される。製品ページには、ビットマップキャッシュとバックグラウンドプリフェッチのエントリポイントを含む、ページレンダリングAPIの完全なリファレンスが掲載されている