Delphi PDF 函式庫中的範圍檢查錯誤向來以難以追蹤而聞名,因為它們並沒有遵循一致的輸入模式。同一份文件在某台機器上會產生錯誤,在另一台卻不會;相同的程式碼路徑在 3 頁的檔案上會觸發例外,但在 12 頁的檔案上卻能順利執行。這種不一致性幾乎總是能追溯到單一的根本原因:PDF 頁面物件並非按照檔案順序儲存。如果函式庫在建立其內部頁面陣列時,是透過循序掃描物件,而不是走訪目錄 (catalog) 所宣告的頁面樹 (page tree),那麼它所建構的索引的有效範圍將無法符合呼叫端的預期,而範圍檢查機制就會在最糟糕的時刻抓到這種不符
Delphi 中的範圍檢查機制如何運作
當 {$R+} 編譯器指令啟用時(在 Debug 組態中的預設值),Delphi RTL 會在執行階段驗證每個陣列索引、字串註標 (subscript) 及列舉型別指派。越界存取會引發 ERangeError,而非默默地讀取相鄰記憶體。這種行為很有價值:它能及早暴露潛在的錯誤,防止錯誤破壞資料結構而到幾百行後才失效。令人沮喪的是,例外觸發的位置是在存取點,而非索引一開始被錯誤計算的地方。當呼叫堆疊 (call stack) 顯示這是在 PDF 單位 (unit) 的深層巢狀方法中時,真正的錯誤通常在好幾個堆疊框架之前
複合布林條件讓情況變得更糟。Delphi 採用短路 (short-circuit) 語意由左至右評估 and 運算式,但短路只有在左側為 False 時才會跳過評估。像這樣的運算式:
if FDocStarted and (DestIndex < Length(PageArr)) and
(PageArr[DestIndex].PageObj <> nil) then
看起來很安全,但它只有在 FDocStarted 為 True 且 DestIndex 為非負數時,才能防範越界索引。當 DestIndex 為負數時,檢查 DestIndex < Length(PageArr) 起不了作用,因為在有號運算中,將負整數與非負長度進行比較會傳回 True,後續的陣列存取仍然會引發範圍錯誤。將邊界檢查移到最外層才是正確的修正方式:
if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
Result := PageArr[DestIndex].PageObj
else
Result := nil;
end
else
raise ERangeError.CreateFmt(
'Page index %d is out of range (0..%d)',
[DestIndex, Length(PageArr) - 1]);
這是機械式的修補。它能阻止程式崩潰。但它並未解釋為什麼 DestIndex 一開始會收到落在有效範圍之外的值
真正的病因:物件順序與頁面順序的差異
ISO 32000-1 §7.7.3 將頁面樹定義為 Pages 節點的樹狀結構,其 Kids 陣列列出顯示順序的頁面物件。檔案中這些物件的偏移量取決於寫入器的任意選擇;位元組串流中第 20 號物件的實體位置可能在第 3 號物件之前。如果一個函式庫建立頁面清單的方式是依照物件編號順序疊代交叉參照表 (cross-reference table),而非跟隨 Kids 鏈結,將會產生與使用者預期相左的序列。當文件產生器碰巧按順序寫入頁面時,一切正常。但若非如此,函式庫的頁碼編排與呼叫端的頁碼編排之間就會出現落差,進而產生落在 PageArr 之外的索引
正確的方法是從目錄 (catalog) 開始,解析 /Pages 間接參照,並遞迴走訪 Kids 陣列。對於沒有中介 Pages 節點的扁平文件,其走訪過程很直覺:
procedure BuildPageIndexFromTree(
const KidsArray: THPDFArray;
var PageArr: TPageObjArray);
var
i, Idx: Integer;
Child: THPDFObject;
ChildType: string;
begin
for i := 0 to KidsArray.Count - 1 do
begin
Child := KidsArray.GetIndirectObject(i);
if Child = nil then
Continue;
ChildType := Child.GetNameValue('/Type');
if ChildType = 'Page' then
begin
Idx := Length(PageArr);
SetLength(PageArr, Idx + 1);
PageArr[Idx].PageObj := Child;
end
else if ChildType = 'Pages' then
begin
// intermediate node: recurse into its Kids
BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
end;
end;
end;
執行完這段程式碼後,PageArr[0] 會是檢視器顯示的第一頁,無論該物件在位元組串流中的位置為何。假設呼叫端傳入以顯示順序為準的索引,現在就能正確對應,範圍錯誤也會隨之消失
寫死的應急修補反而讓問題惡化
在從未找出根本原因的程式碼基底中,經常可以發現啟發式的修補:如果總頁數為 3,就交換第一頁和最後一頁;為特定產生器來源的文件旋轉索引;或者當第一個物件編號超過某個閾值時套用偏移量。每一個修補都剛好符合編寫當時手邊的測試檔案。然而,若加入一個不同的 PDF 來源,其中一個修補就可能在錯誤的時間觸發,產生一個錯上加錯的索引:錯在它是從順序混亂的陣列中計算出來的,而錯加上錯是因為在它之上套用了不適用的對應規則。結果範圍檢查器在下游某處捕捉到它,而堆疊追蹤資訊卻指不出任何有用的線索
唯一有建設性的途徑,就是移除所有的啟發式對應,並將頁面陣列的建構替換為正確的樹狀走訪。只要索引的建構方式正確無誤,就不需要任何修補,而範圍檢查器也會成為得力助手,而非阻礙
如果您正在維護一個有這種情況的函式庫,請暫時在 Release 建置中啟用範圍檢查,並將它與各種來源的 PDF 文件進行測試:由 Word 產生的文件、由 LaTeX 產生的文件、掃描器韌體產生的文件,以及 PDF 分割合併工具產生的文件。會觸發例外的檔案,正是那些其頁面物件順序偏離您的程式碼所假設的走訪順序的檔案。它們每一個都是一個資料點,而不是獨立的程式錯誤
對於呼叫 Delphi PDF 函式庫的新程式碼,實用的建議是將函式庫的頁數視為權威值,且絕對不要在未確認其落在 0..PageCount - 1 範圍內之前,傳入從外部資料進行算術運算而得出的索引。HotPDF 元件會在 BeginDoc 或載入文件之後,透過 THotPDF.PageCount 公開解析出的頁數;該值永遠反映頁面樹的走訪結果,因此可安全地作為任何索引算術運算的上限