在 PDFium Component 檢視器執行列印工作時點選 Cancel,有時毫無作用:迴圈繼續渲染剩餘的每一頁與每一份副本,工作照樣送達印表機。原因出在 Free Pascal 的 for 迴圈:它在迴圈進入的那一刻就把上界固定下來,只此一次,因此在迴圈中途把 CopyCount 或 ToPage 背後的變數歸零,對已經在跑的東西毫無影響。這個 for 迴圈邊界缺陷,是同一輪 PDFiumPas Delphi/FPC 相容性稽核挖出的第五個陷阱——同一輪稽核先前已產出另外四個跨編譯器陷阱——而且與那四個不同,它完全住在檢視器的列印程序裡,直到一位測試人員在漫長的列印工作中按住 Cancel,才真正注意到印表機從未停下來
為什麼點選 Cancel 之後列印工作還是繼續跑?
列印工作之所以繼續,是因為取消檢查只重設了餵給迴圈邊界的變數,而不是迴圈本身——那些邊界早在每個迴圈啟動時就被 Free Pascal 鎖定了。在 PDFViewer 與 MultiPageViewer 範例中,位於「列印」按鈕背後的 SpeedButtonPrintClick 處理常式以三層巢狀迴圈建立每份工作:外層走訪自動分頁的份組、中層走訪頁面、內層走訪同一頁未自動分頁的副本。PrintDialog.Collate 決定由哪個計數器——CollateCopyCount 或 CopyCount——實際持有要求的份數,另一個則維持為一。取消必須一次貫穿三層,而這段程式碼的第一版嘗試的做法,是在 Cancel 變為 True 的那一刻直接重設邊界變數本身
for CollateCopy:= 1 to CollateCopyCount do
for Page:= FromPage to ToPage do
for Copy:= 1 to CopyCount do
begin
// ……渲染頁面並送往印表機……
Application.ProcessMessages;
if Cancel then
begin
CollateCopyCount:= 0;
ToPage:= 0;
CopyCount:= 0;
end;
end;
Printer.EndDoc; // 無論 Cancel 是否觸發都會執行
這個意圖不難理解:如果定義還剩多少份組、頁面與副本的計數器全部降為零,迴圈理應把工作耗盡、自行結束。而 Printer.EndDoc 在迴圈之後無條件執行,不管迴圈是怎麼結束的,於是即便使用者自認為已經停止的工作,仍會帶著點擊被注意到之前渲染好的每一頁,被提交給多工緩衝處理器
Free Pascal 在 for 迴圈啟動時鎖定了什麼
Free Pascal 對 for 迴圈終值的求值,只發生在迴圈開始的那一刻,在該迴圈的整個生命週期內不會再有第二次。for Page := FromPage to ToPage do 只讀取 ToPage 一次,用來計算要跑幾次迭代,之後迴圈對名為 ToPage 的變數不再有任何興趣,只認它已經擷取到的迭代次數。從迴圈本體內設定 ToPage := 0,改變的是一個執行中的迴圈已不再查閱的變數,這正是為什麼 Cancel 已經為 True,印表機卻還能再收好幾輪頁面、有時甚至是全部頁面的原因
從 C 或 C++ 帶過來的這個習慣完全合理,而 PDFium Component 的 C++Builder 檢視器範例與 Pascal 版本相似到可以直接對照。它的 for (Copy = 1; Copy <= CopyCount; Copy++) 在每一輪都以 CopyCount 的即時值重新檢查 Copy <= CopyCount,所以在那裡把計數器歸零,確實會在下次檢查時結束迴圈。C++Builder 範例帶著一模一樣的「計數器歸零」程式碼而且行得通,這正是讓同樣的點子看起來可以安全重用在旁邊那個 Pascal 組建裡的原因——兩者就住在同一個儲存庫中
為什麼 Delphi 組建沒有踩到同一個缺陷?
Delphi 的 PDFViewer 範例從不倚賴迴圈去察覺邊界變了,因為它的取消路徑是靠例外展開,而不是靠比較。它的最內層迴圈在 Cancel 變為 True 的那一刻呼叫 RTL 的 Abort 程序,引發一個無聲的 EAbort,直接穿越三層巢狀的 for 迴圈,傳遞到包住整個列印區塊的處理常式
Printer.BeginDoc;
try
for CollateCopy:= 1 to CollateCopyCount do
for Page:= FromPage to ToPage do
for Copy:= 1 to CopyCount do
begin
// ……渲染頁面並送往印表機……
Application.ProcessMessages;
if Cancel then
Abort; // 引發 EAbort,一次展開全部三層迴圈
end;
Printer.EndDoc;
except
on E: EAbort do
Printer.Abort;
else
begin
Printer.Abort;
raise;
end;
end;
例外不在乎引發點與捕捉它的處理常式之間隔著幾個 for 迴圈,而這正是本問題需要的特性。這種穩健性並不是針對前述邊界鎖定行為的刻意防禦——Delphi 範例的作者只是恰好拿起了另一件工具。不過這種以例外為基礎的逃脫方式,仍值得點名為更牢固的模式:即便日後加入第四層巢狀它依然存活,而一串手動擺放的 Break 敘述,每次迴圈巢狀改變時都必須有人記得重新補上
修正方式:在每一層巢狀用 Break,並以 PrintSucceeded 旗標把關
PDFiumPas v2.27.0 出貨的修正保留了 Lazarus 與 C++Builder 檢視器範例中的三層迴圈結構,但讓取消在每一層都變得明確,並把「迴圈停止」與「工作是否提交」拆開,放進一個只有在迴圈完全結束之後才會被讀取的旗標
Printer.BeginDoc;
try
Cancel:= False;
for CollateCopy:= 1 to CollateCopyCount do
begin
for Page:= FromPage to ToPage do
begin
for Copy:= 1 to CopyCount do
begin
// ……渲染頁面並送往印表機……
Application.ProcessMessages;
if Cancel then
Break;
end;
if Cancel then
Break;
end;
if Cancel then
Break;
end;
PrintSucceeded:= not Cancel;
finally
if PrintSucceeded then
Printer.EndDoc
else
Printer.Abort;
end;
PrintSucceeded 是刻意只計算一次的:就在三層巢狀迴圈退出的那一刻,依據的只有 not Cancel。迴圈本體內的任何東西都無權自行決定工作是否成功——迴圈只有兩種結束方式:份組、頁面與副本全部跑完,或者撞上 Cancel 觸發的 Break 串,而 PrintSucceeded 是事後讀取結果,不是邊跑邊追蹤。正是這種計算方式,讓 finally 區塊在 Printer.EndDoc 與 Printer.Abort 之間的選擇值得信賴:它絕不會在迴圈真正底定之前就觸發
稽核你自己的取消驅動列印迴圈
有三項檢查可以帶到這個列印程序之外。絕不要假設 Pascal 的 for 迴圈會察覺邊界變數在啟動後發生變化;如果迴圈需要提前結束,就用 Break 直接說清楚,而且取消需要跨越的每一層巢狀都要放,不是只放最內層。一旦巢狀深到漏放一個 Break 是合理風險,就改用天生會展開的逃脫機制,例如例外——Delphi 範例的 Abort/EAbort 組合免費拿到了這一點。任何像 EndDoc 這樣的提交步驟,都要用一個嚴格在迴圈之後計算的旗標把關,絕不在迴圈之內,這樣提前停止的工作才永遠不會被誤認為順利完成的工作
修正這個迴圈的同一輪稽核,也收緊了九個檢視器範例在取消按鈕可見期間的鍵盤處理:現在除了 Esc 以外的所有按鍵都會被吞掉,因此在列印或搜尋進行中,一個誤觸的 Ctrl+P 或 Ctrl+F 再也無法在上面再啟動第二個。這個重入性修正機制不同、是另一個缺陷,但它同樣源自「Cancel 在作業中途到底做了什麼」的同一輪審視。兩個修正都值得帶走的習慣,與其說是編碼習慣,不如說是測試習慣:在共用列印程序實際出貨的每一種編譯器上都演練取消,因為像「清空計數器、相信迴圈會察覺」這樣的點子,可以在 C++Builder 下通過測試、未經驗證地進入 Free Pascal 組建、在原始碼裡讀起來一模一樣,然後以沒人看得見的方式失敗——直到有人按住 Cancel 夠久,親眼看著工作照樣跑完為止。至於這套取消邏輯腳下的列印設定基礎,用 PDFium VCL 元件列印 PDF 文件的逐步指南涵蓋了其餘部分
這項列印取消修正隨 Delphi、C++Builder 與 FPC/Lazarus 版的 PDFium Component 一同出貨;該元件在每次發行時都會以全部三種編譯器執行同一套範例,讓這類缺口由建置矩陣抓到,而不是靠支援單發現