一個渲染出空白畫面的 PDF 渲染器,通常繪圖程式碼本身根本沒有任何錯誤。在 Delphi 與 C++Builder 適用的 HotPDF Component 中,有四個各自獨立的缺陷,讓頁面渲染成空白,而每一行日誌卻依然乾乾淨淨:帶有前導斜線的名稱運算元、一個反向的 cm 串接運算,以及一個讀到零的 token 索引。這些錯誤沒有一個拋出例外,沒有一個留下日誌。內容串流被正確地 token 化,運算子分派器辨識出每一個運算子,影像 XObject 也被解碼成一張合法的點陣圖,然後頁面就這樣變成空白。這種組合──每個階段都回報成功、卻沒有產生任何可見內容的處理管線──正是一次悄悄失手、而非失敗的查找或索引所留下的特徵。本文是對這樣一整個錯誤家族的事後檢討,也回顧了讓它存活了 38 個版本的測試紀律問題
為何 PDF 渲染器會完全渲染不出任何東西?
因為在 PDF 渲染器中,一次失敗的資源查找,和一頁真正空白的內容,在外觀上是無法區分的。內容串流中的名稱運算元,與資源字典中的鍵,其實是兩個不同的字串空間,而 HotPDF 卻在兩者之間做比對、卻沒有做正規化處理。token 化工具讀取 /Im0 時會保留斜線,因為這個 token 本來就是這樣寫的;而已載入的 /Resources /XObject 字典,儲存的鍵卻是 Im0,因為剖析器在建構字典鍵時,會把分隔符去掉。因此每一次針對運算元名稱的 FindValue 查找都回傳了 -1。這個問題的影響範圍,比影像本身還要廣。ISO 32000-1 §8.9 涵蓋 Do、§8.4 涵蓋 gs 及其 /ExtGState 查找、§8.6 涵蓋 cs 與 CS、§8.7.4.3 涵蓋 sh。這五個運算子,全都以原始運算元作為資源子字典的鍵,所以全部五個都失手了。具名色彩空間會退回到 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;
還有第二個相關的漏接情況,藏在更深的一層。渲染器只針對串流與字典設有型別化的解析器,所以一個指向頂層陣列物件的間接參照──常見的例子是 /CS0 5 0 R、另一端接的是 [/Separation ...]──經由這兩種解析器都會解析成 nil,並退回到未解析的連結狀態。加入一個通用物件解析器,就一次解決了具名色彩空間與函式陣列的問題。如果你正在接線陰影字典,同樣的解析紀律也適用於 軸向與放射狀陰影路徑,那裡的 /Function 條目也非常常見是間接參照
cm 運算子與一次寫反了的串接運算
第二個缺陷,會把影像放到偏離頁面大約十萬像素的位置,這看起來就跟完全沒畫出來一模一樣。ISO 32000-1 §8.3.4 以列向量(row vector)定義 PDF 的變換,而 cm 運算子會把其運算元矩陣 M,以 M × CTM 的方式串接到目前的變換矩陣上──先套用 M,再套用既有的 CTM。HotPDF 是透過 HPDFMatMul(A, B) 來組合矩陣,這個函式會先套用 B、再套用 A。因此正確的呼叫方式,是把舊的 CTM 當作 A 傳入。而出貨的程式碼卻把運算元矩陣當作 A 傳入,產生出來的是 CTM × M
順序顛倒對單一一次 cm 而言無傷大雅,但對標準的兩步驟慣用寫法而言卻是災難性的。用 1 0 0 1 x y cm 接著 w 0 0 h 0 0 cm 來放置一張影像,正確的串接方式會先把單位正方形依 (w, h) 縮放,再依 (x, y) 平移。而在順序顛倒的串接下,平移會先套用,縮放接著把它乘大,所以一張名義上位於 (60, 400)、縮放為 200 乘 120 的影像,最終會落在 (12000, 48000) 這個位置。點陣傳輸(blit)開頭的裁切測試會拒絕它,該次點陣傳輸就被略過,而系統中沒有任何一處會回報這個問題
// 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 字符路徑與 嵌入式字符輪廓路徑 這兩處,從一開始寫法就是對的,因為一旦顛倒,字符位置就會明顯地全部塌縮到原點,早就有人被迫修正過這個問題。兩種寫法在同一個單元裡並存了三十幾個版本,各自在自己的函式裡都是對的,卻沒有任何審查者發現,因為單獨看任何一個呼叫點,都不覺得有什麼不對
當 token 索引差一個位置時會發生什麼事?
結果是十二個運算子,在語法上被正確處理,語意上卻形同死物。渲染器裡的運算元存取函式是 NumAt(Back),它讀取的是 Tokens[OpIndex - Back],而 OpIndex 是運算子 token 本身的索引。因此,一個單運算元的運算子,應該在 back 為 1 的位置找到它的數值。其中十二個卻被寫成 NumAt(0),這樣讀到的是運算子 token 本身,無法通過 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),全部都是對的,所以審查者掃視這個函式時,看到的是一整片看似合理的索引運算,其中卻埋著十二個錯誤條目
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 個版本都保持綠燈?
因為斷言的條件太弱,弱到無法區分一頁完整渲染的頁面,和一頁只渲染了一部分的頁面。渲染冒煙測試斷言的內容諸如「輸出的點陣圖不是全黑」、「頁面不是空白」,或「影像摘要值不為零」。當文字有渲染出來、而影像卻沒有時,這些條件全部都成立。文字繪製正常,所以畫面緩衝區從來不是均一色,摘要值也從來不是零,於是測試套件回報成功,而整個影像管線實際上卻是一段死程式碼。對圖形測試而言,弱斷言的誘惑力特別強,正是因為強斷言看起來很脆弱。沒有人想要一個因為反鋸齒邊緣位移一個像素就壞掉的測試,於是自然而然的退讓,就是去斷言某個任何合理變更都不會違反的條件──而這種退讓,也讓你落到一個任何不合理變更同樣不會違反的判斷式上。一個分離色彩空間測試,斷言輸出結果與黑色可區分;灰色配白色能通過這個測試,白色配白色同樣能通過。這個測試量測的,根本不是有沒有畫出正確的顏色,而只是畫布上有沒有發生任何事情
你要如何寫出一個真的會失敗的渲染斷言?
做法是計算預期顏色的像素數量、是否符合預期數量,讓位置與尺寸自然地從這個計數中反映出來。替代的做法紀律,是手工建構一份最小化的 PDF,一個檔案只驗證一項視覺事實,並斷言有多少像素落在某個特定 RGB 三元組的容許誤差範圍內。一張放在已知偏移量、尺寸 200 乘 120 的純紅色影像,必須產生大約 24000 個紅色像素。如果資源查找失手了,計數就是 0。如果 cm 串接反了,計數就是 0。如果影像用錯誤的色彩空間渲染,計數還是 0。一個數字就能同時抓出這三種問題,而容許誤差區間則吸收掉了那些原本讓大家不敢做精確比對的反鋸齒雜訊
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');
有四個冒煙測試依這種方式重寫──一個 Type 4 色調變換、一次影像 Do 置放、一個選用內容可見性案例,以及一個 Tr 筆畫模式──這四者合起來,就揭露了整個錯誤家族。這才是真正的教訓,而且它的適用範圍遠遠超出這個程式碼庫:在一個渲染管線裡,斷言必須點名顏色本身。任何比這更寬鬆的做法,檢查的只是渲染器有沒有跑起來,而不是它有沒有真的畫出東西。如果你正在打造自己的「頁面轉點陣圖」測試框架,頁面光柵化逐步解說一文 正是把像素計數輔助函式,接上你第一個回歸測試的自然起點
誠實面對的邊界限制
有兩個限制值得直接說清楚。文字裁切渲染模式 4 到 7,繪製時會依照其基底的填色或描邊模式來處理,因為渲染器並沒有對由字符輪廓所累積出的裁切路徑建立模型;依賴文字形狀裁切的文件,渲染出來的會是文字本身,而不是底下被裁切出來的美術內容。此外,本文所描述的像素計數做法,只是一種冒煙測試技巧,不是一套一致性驗證套件──它證明的是某個特定的視覺事實有抵達畫面緩衝區,這個門檻遠低於證明輸出結果與某個參考光柵化器完全相符。然而,這個門檻,正是這四個錯誤在長達三年的版本發布中,始終未能跨過的門檻
本文所討論的渲染器,隨附於標準版 HotPDF Component(適用於 Delphi 與 C++Builder)之中;產品頁面提供完整的頁面渲染 API 參考文件,包含點陣圖快取與背景預先擷取進入點