把一頁 PDF 算繪到 Windows 裝置內容上做列印預覽,等於把三套座標系統擺進同一行程式碼,而它們難得一致。PDF 頁面以點為單位,原點在左下角。螢幕 DC 以像素為單位,原點在左上角,還帶著一個由您決定的縮放係數。而預覽理應預測的那個印表機 DC,以裝置解析度的像素度量,卻把原點擺在可列印區域的角落,不是紙張的角落。三者中任何一個弄錯,預覽都看起來好好的,印出來的那一頁卻位移、縮放不對,或是沿著某一邊被裁掉。常見的症狀是一張有框線的表單,預覽時置中得漂漂亮亮,印出來卻把上緣與左緣的線切掉了,因為雷射印表機沒辦法在最外圈那幾公釐內下墨,而沒有人把這件事告訴預覽。losLab PDF Library(PDF Library for Delphi)用裝置內容算繪呼叫、一層虛擬印表機設定,以及依印表機自身度量產生的預覽點陣圖,涵蓋了整條路徑,而最後那一項,正是讓預覽對那圈邊界誠實的關鍵
紙張幾何不等於可列印幾何
任何列印目標都由兩個矩形描述,而多數預覽臭蟲就住在兩者之間的位移裡。紙張矩形是實體紙張。可列印矩形則是列印引擎實際搆得到的那塊較小區域,它被一圈硬體邊界內縮,這圈邊界隨印表機型號而不同,有時甚至隨紙匣而不同。函式庫的列印層兩者都量。底層的 TPLPrinter 類別以 PageWidth 與 PageHeight 公開可列印區域,以 FullPageWidth 與 FullPageHeight 公開整張紙,並以 PrintOffsetX 搭配 PrintOffsetY 公開兩者原點之間的落差,全都是以 GetDPI 所回報的解析度下的裝置像素為單位。一份誠實的預覽會把這同一組數字按比例縮到螢幕解析度,而不是把頁面畫進控制項剛好擁有的那個矩形裡。略過這一步,預覽就無聲地假設邊界為零,而那正是沒有任何一台真實印表機會用的值
透過 RenderPageToDC 做螢幕預覽
對於螢幕上的預覽控制項,RenderPageToDC(DPI, Page, DC) 會把已載入文件的某一頁直接畫到任何 GDI 裝置內容上,不論那是一塊 TPaintBox 畫布、一張離螢幕點陣圖,還是一個中繼檔 DC。DPI 引數設定的是縮放。96 在傳統顯示器上大致相當於 100% 檢視,把它加倍,算繪出來的尺寸也加倍
procedure TPreviewForm.PreviewBoxPaint(Sender: TObject);
begin
// 這三個是具黏性的函式庫狀態,不是逐次呼叫的參數:
FPdf.SetRenderDCOffset(FOffsetX, FOffsetY);
FPdf.SetRenderDCErasePage(1);
FPdf.SetRenderCropType(0);
FPdf.RenderPageToDC(FPreviewDpi, FCurrentPage, PreviewBox.Canvas.Handle);
end;
陷阱在於 DC 算繪路徑是由具黏性的函式庫狀態掌舵,不是由逐次呼叫的參數掌舵。SetRenderDCOffset、SetRenderDCErasePage 與 SetRenderCropType 都會一直留著直到有東西改了它們,所以一個在使用者調整過放大檢視之後才跑的縮圖迴圈,會繼承先前那條程式碼路徑留下的任何位移或裁剪。症狀是預覽只在特定的瀏覽順序下才會飄掉,這種臭蟲重現起來的難受程度大概到頂了。像上面那樣在繪製處理常式的開頭把所有相關狀態都設好,一毛錢都不必付,卻消掉了一整類問題。附近還藏著第二個乘數。有效輸出解析度是算繪縮放比乘上 DPI 引數,而 SetRenderScale 雖然預設為 1.0,一旦改了同樣會留著,於是某個把它調高了的匯出功能,會悄悄地把之後每一次預覽都重新縮放,直到有東西把它設回來為止
捲動式檢視器與局部重繪有專用的變體。RenderPageToDCClip 除了裝置內容之外還收一份裁剪規格,於是讓視窗中的一條帶狀區域失效時,只會重繪那一條,而不是把整頁重新點陣化。在大幅面頁面的高倍放大下,這就是一個跟得上捲軸的檢視器,跟一個在捲軸後面拖出殘影的檢視器之間的差別
一份與預覽相符的列印工作
列印這一側透過虛擬印表機運作。NewCustomPrinter 把一台系統印表機複製成一份函式庫私有的設定,而 SetupPrinter 在不動到整台機器共用的 DevMode 的前提下調整那份副本:紙張以設定 1 傳入(一個 DMPAPER_* 常數),方向以設定 11 傳入。回報是隔離。一項服務可以印 A4 標籤,而主機的預設印表機仍停在 Letter 上,事後也不必還原任何東西
var
Pdf: TPDFlib;
Virt: WideString;
Opt: Integer;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report.pdf', '') <> 1 then
raise Exception.Create('load failed');
Virt := Pdf.NewCustomPrinter(Pdf.GetDefaultPrinterName);
Pdf.SetupPrinter(Virt, 1, 9); // 設定 1 = 紙張,DMPAPER_A4
Pdf.SetupPrinter(Virt, 11, 1); // 設定 11 = 方向,1 = 直向
Opt := Pdf.PrintOptions(1, 1, 'Monthly Report'); // 符合紙張,自動旋轉 + 置中
Pdf.PrintDocument(Virt, 1, Pdf.PageCount, Opt);
finally
Pdf.Free;
end;
end;
PrintOptions 值得細讀。它傳回一個選項控制代碼,您必須把它傳給 PrintDocument 或 PrintPages;它不是環境狀態。建好選項卻忘了把控制代碼傳過去,會無聲地失敗。工作會以預設值印出去,而在有人期待符合紙張的政策、結果一頁超大的內容被裁掉之前,沒有人會注意到。頁面縮放引數就是那項政策所在之處。不縮放保住的是尺寸準確性,這對要拿尺去量的表單很要緊。符合紙張則把一切重新縮放到紙上。縮小過大頁面則放過正常頁面,只有在某一頁超出可列印區域時才插手,而這通常是混合文件集合的正確預設值。自動旋轉並置中的旗標,不必第二條程式碼路徑就處理掉橫向頁面
已經透過 VCL 對話框流程管理一個 TPrinter 的應用程式,可以直接把它交出來。PrintDocumentToPrinterObject 與 PrintPagesToPrinterObject 接收設定好的 TPrinter 實例,這讓標準列印對話框仍是面向使用者的設定介面,而由函式庫負責頁面算繪。在同一條程式碼路徑裡把兩種做法混用,往往會把這整套工作原本要消滅的幾何漂移又請回來,所以請挑一種。虛擬印表機路線適合無人看顧的服務,TPrinter 路線適合互動式應用程式
選擇性輸出的做法一樣。PrintPages 收一個範圍字串,所以傳入虛擬印表機名稱、'2-5,12' 與選項控制代碼,就會在幾何契約完好無損的情況下印出第 2 到第 5 頁與第 12 頁,同一套語法也驅動列印到檔案的各種變體。在沒有實體裝置連著的無人環境裡,那些檔案變體就是實務上的答案:在一台根本沒有驅動程式佇列的建置伺服器上對列印幾何做迴歸測試。每次建置都把同一份文件經同一組選項算繪成一個檔案產物,幾何迴歸就變成一次差異比對,而不是三週後的一封客戶回報
依印表機自身度量產生的預覽點陣圖
一份以 96 DPI 對著假設的頁面尺寸算繪出來的預覽,回答的是錯的問題。它顯示的是這一頁長什麼樣,不是這台印表機會在這張紙上放什麼。GetPrintPreviewBitmapToString 補上這道缺口,做法是用跟最終工作相同的自訂印表機與相同的選項控制代碼來建構預覽,於是紙張尺寸、方向、縮放政策、旋轉與硬體位移全都餵進了那張點陣圖。傳回來的東西,就是紙上會呈現的東西
procedure ShowPrinterTruePreview(Pdf: TPDFlib; const Virt: WideString; Opt: Integer);
var
Data: AnsiString;
Strm: TMemoryStream;
Bmp: TBitmap;
begin
Data := Pdf.GetPrintPreviewBitmapToString(Virt, 1, Opt, 1200, 0);
Strm := TMemoryStream.Create;
try
Strm.WriteBuffer(PAnsiChar(Data)^, Length(Data));
Strm.Position := 0;
Bmp := TBitmap.Create;
try
Bmp.LoadFromStream(Strm);
PreviewImage.Picture.Assign(Bmp);
finally
Bmp.Free;
end;
finally
Strm.Free;
end;
end;
MaxDimension 引數為點陣圖的長邊設上限。1200 像素對一個預覽對話框而言依然銳利,而且即使碰上 E 尺寸的工程圖也讓記憶體用量維持節制,那種圖若以印表機的 600 DPI 做全解析度算繪,會膨脹到好幾 GB
記住使用者的印表機選擇
會在各次工作階段之間忘掉自己設定的列印對話框,自己就會製造出支援工單。DevMode 這一對函式 GetPrinterDevModeToString 與 SetPrinterDevModeFromString,把一台印表機完整的驅動程式設定序列化成一段不透明字串,讓您能塞進使用者偏好設定裡,下次工作階段再還原,其中也包含那些沒有一般 API 願意去建模的驅動程式專屬選項。請以 GetPrinterNames 取得的名稱來保存印表機,絕對不要用清單索引。每次新增或移除印表機,索引順序就會變,於是下次清單一移動,存下的索引就悄悄指到錯的裝置。當記住的裝置已完全消失時,GetDefaultPrinterName 負責兜底
紙匣選擇讓這則保存故事完整。GetPrinterBins 回報驅動程式所公開的紙張來源,這對信頭紙的工作流程很要緊:第一頁抽信頭紙匣,其餘抽普通紙。使用者期待應用程式連同其他一切一起記住這項政策,而一份落在錯紙上的列印工作讀起來就是個臭蟲,即使那份 PDF 的每一個位元組都正確無誤
預覽與列印共用同一套引擎
最後有一個決定悄悄左右著保真度。算繪引擎的選擇對螢幕與印表機這兩種目的端都生效,所以人會忍不住想用快的引擎做預覽、用精準的引擎去印。請忍住。讓預覽與工作走不同引擎,會把印表機真實預覽當初就是要消除的那份保真度漂移又請回來,而且是以只在紙上才現形的方式。內建、Cairo 與 PDFium 三套引擎之間的取捨,在 Delphi 中的多引擎 PDF 算繪裡秤過了;挑一套,然後兩邊都用它
大到不好在列印前從容載入的文件,可以透過 大型 PDF 的合併、分割與直接存取裡所描述的直接存取路徑開啟,它能從檔案控制代碼把頁面算繪到裝置內容上,而不必建出文件樹。完整的列印 API 參考在 losLab PDF Library for Delphi 產品頁