使用者在 Delphi 檢視器裡填好一份 PDF 表單,按下列印,紙出來時每個欄位都是空的,Letter 尺寸的頁面還被拉寬去填滿 A4。這兩個毛病都坐在列印迴圈裡:PDFium Component 只把欄位值畫進 bitmap(或把它們烤進一份扁平化副本),而每一頁都需要同一個保持長寬比的縮放係數
表單層是第二遍繪製,而且需要 bitmap
在 Windows 上,裝置內容多載 TPdf.RenderPage(DC, Left, Top, Width, Height, Rotation, Options) 把印表機的 HDC 直接交給 FPDF_RenderPage。那次呼叫會畫出頁面內容,設定 reAnnotations 時還會畫上標記註解。互動式表單層永遠到不了 DC——它屬於 PDFium 的表單填寫環境,由 FPDF_FFLDraw 繪製,而後者的目標只能是 FPDF_BITMAP,沒有 HDC 版本。TPdf.RenderPage 的兩個 bitmap 多載,只要文件是以 FormFill 為 True(預設值)開啟的,就會對同一批像素依序跑 FPDF_RenderPageBitmap 與 FPDF_FFLDraw。選項裡帶 rePrinting 時,PDFium Component 會在表單這一遍之前拿掉欄位高亮,之後再還原預設的高亮顏色與透明度,所以可編輯欄位外面的底色留在螢幕上。rePrinting 同時對應引擎的 FPDF_PRINTING 旗標,把輸出標成列印——ISO 32000-1 §12.5.3(表 165)用註解旗標位元 3 處理的正是這種情況:符合規範的讀取器,不論註解在螢幕上看起來如何,只要它的 Print 位元沒設,就不會印到紙上
其餘由平台決定。在 PDFium 引擎裡,FPDF_RenderPage 透過 Windows GDI 畫進裝置內容,只存在於 Windows 版;而 FPDF_FFLDraw 畫進 bitmap、別的都不行,每個平台都有。在 Linux 與其他非 Windows 目標上根本沒有 GDI 可呼叫,所以 HDC 多載是靠先算一張 bitmap(含表單層)、再把它畫到 handle 背後的畫布上來達成。於是裝置內容這條路在非 Windows 上會帶著欄位值,在 Windows 上反而少了它們。Delphi 與 Lazarus 共用的程式碼請把分支寫明:文件有表單環境、而且欄位值該印上紙時,每個平台都走 bitmap
列印工作該走哪條路?
看紙上必須帶什麼來選。表單活動值指向 bitmap 路徑,向量輸出指向 HDC 路徑,而扁平化副本用一次存檔加一次重開,兩者都能給您。SetPdfPrintMode 是整館共用的,所以請在兩份工作之間改,絕不要在兩頁之間改
| 路徑 | 表單值是否上紙 | 輸出 | 成本 |
|---|---|---|---|
RenderPage(DC, …) | 不會,Windows 上 | 向量 GDI;SetPdfPrintMode 可選 EMF(預設)或 PostScript 等級 | 排隊資料最小、最快 |
RenderPage 畫進 TBitmap,再 StretchDIBits | 會,包含尚未存檔的編輯 | 以您要求的像素尺寸算圖 | 每頁 Width × Height × 4 位元組,再加上 DIB 副本 |
RenderPageBanded | 會 | 點陣,一次一條 | 記憶體裡只有一條帶,而不是整張紙 |
扁平化副本,再 RenderPage(DC, …) | 會,成為烤進頁面的內容 | 向量 | 存檔、重開、GenerateFormAppearances、FlattenAllPages(FLAT_PRINT) |
一張滿版 A4 在 600 dpi 下約是 4960 × 7016 個裝置像素,以每像素 32 位元計,一頁就是 139,197,440 位元組;為了 StretchDIBits 再複製一份進 DIB,峰值直接翻倍,對 32 位元行程式是沉重負擔。對檢視器的列印按鈕來說,bitmap 路徑仍然是正確的預設:使用者在欄位裡打了字再按列印,期待的就是紙上看到那個值,而分帶能把記憶體壓住。會離開應用程式的文件,例如歸檔對帳單,扁平化副本更合適——值變成普通的頁面內容,印表機收到的是向量輸出
uses
System.SysUtils, System.IOUtils, PDFium, FPdfUtils; // FPdfUtils:FLAT_PRINT
function OpenFlattenedPrintCopy(Source: TPdf; out TempName: string): TPdf;
begin
TempName := TPath.Combine(TPath.GetTempPath, TPath.GetRandomFileName + '.pdf');
if not Source.SaveAs(TempName) then
raise Exception.Create('Could not write the print copy');
Result := TPdf.Create(nil);
try
Result.FileName := TempName; // FormFill 維持 True(預設值)
Result.Password := Source.Password; // 副本保留原本的加密
Result.Active := True;
if not Result.Active then // 載入失敗時 TPdf 會留在非作用中
raise Exception.Create('Could not reopen the print copy');
Result.GenerateFormAppearances; // 從目前的 /V 重建 /AP
if not Result.FlattenAllPages(FLAT_PRINT) then
raise Exception.Create('Flattening failed');
except
Result.Free;
TFile.Delete(TempName);
raise;
end;
end;
扁平化這條路有一條順序規則,比旗標本身更重要。FlattenAllPages 把每個 widget 的外觀串流(ISO 32000-1 §12.5.5)烤進頁面內容,而外觀串流就是上一個產生器寫下的東西,/V 裡的欄位值完全不參與。用一個從不重新產生外觀的工具填好的表單,扁平化後會得到舊文字或什麼都沒有,所以 GenerateFormAppearances 要先跑:它從目前的值重建文字欄位外觀,同步核取方塊與單選按鈕的 /AS 狀態,而且它需要文件開啟時 FormFill 就是 True。這些全在副本上做,因為扁平化會改寫它跑在哪個 TPdf 的頁面,而開著的檢視器希望欄位保持可編輯。副本接著走下面列印常式的向量分支,並把表單層關掉;工作結束後釋放它、刪掉暫存檔
把每一頁貼進可列印區域
只縮放一次,取兩個係數中比較小的那個,然後置中。直接拉寬到 Printer.PageWidth × Printer.PageHeight 會讓形狀跟紙不同的每一頁都變形,例如 A4 上的 Letter 頁(612 × 792 points),或直向紙上的橫向頁。印表機這邊用的是可列印區域的裝置像素(HORZRES 乘 VERTRES),驅動程式的邊界已經排除掉了。PDF 這邊,TPdf.PageWidth 與 PageHeight 回報的已經是顯示後的頁面:一頁以 /Rotate 90 儲存的 A4 直向頁,讀回來是 842 × 595 points。ISO 32000-1 §7.7.3.3(表 30)說頁面顯示或列印時應依 /Rotate 旋轉,PDFium 在算圖呼叫內部就套用了,而 RenderPage 的 Rotation 引數只是使用者在檢視器裡轉動檢視時額外加的那一轉。把頁面自己的 PageRotation 傳進去,頁面就轉了兩次。只有當那個額外旋轉是 90 或 270 度時,才為了貼合而交換寬高;這跟管著旋轉下的縮圖貼合與縮放的是同一對規則。下面這段常式把用 PDFium Component 列印 PDF 文件裡的基礎迴圈加上表單值分支,以及一條會丟棄整份工作的取消路徑
uses
Winapi.Windows, System.SysUtils, Vcl.Graphics, Vcl.Forms, Vcl.Printers, PDFium;
procedure FitRect(PageW, PageH: Double; Swap: Boolean;
AreaW, AreaH: Integer; out X, Y, W, H: Integer);
var
T, Scale: Double;
begin
if Swap then
begin
T := PageW; PageW := PageH; PageH := T;
end;
if (PageW <= 0) or (PageH <= 0) then
begin
X := 0; Y := 0; W := AreaW; H := AreaH;
Exit;
end;
Scale := AreaW / PageW;
if AreaH / PageH < Scale then
Scale := AreaH / PageH;
W := Round(PageW * Scale);
H := Round(PageH * Scale);
X := (AreaW - W) div 2;
Y := (AreaH - H) div 2;
end;
procedure BlitBitmap(DC: HDC; Bitmap: TBitmap; X, Y: Integer);
var
InfoSize, ImageSize: DWORD;
Info: PBitmapInfo;
Bits: Pointer;
begin
GetDIBSizes(Bitmap.Handle, InfoSize, ImageSize);
Info := AllocMem(InfoSize);
try
Bits := AllocMem(ImageSize);
try
GetDIB(Bitmap.Handle, 0, Info^, Bits^);
StretchDIBits(DC, X, Y, Bitmap.Width, Bitmap.Height, 0, 0,
Bitmap.Width, Bitmap.Height, Bits, Info^, DIB_RGB_COLORS, SRCCOPY);
finally
FreeMem(Bits);
end;
finally
FreeMem(Info);
end;
end;
function PrintPages(Pdf: TPdf; FromPage, ToPage: Integer; FormLayer: Boolean;
ExtraRotation: TRotation; var Cancel: Boolean): Boolean;
const
Opts: TRenderOptions = [reAnnotations, rePrinting];
var
Page, X, Y, W, H: Integer;
Bitmap: TBitmap;
begin
Result := False;
Printer.BeginDoc;
try
for Page := FromPage to ToPage do
begin
if Page > FromPage then
Printer.NewPage;
Pdf.PageNumber := Page;
FitRect(Pdf.PageWidth, Pdf.PageHeight, ExtraRotation in [ro90, ro270],
Printer.PageWidth, Printer.PageHeight, X, Y, W, H);
if FormLayer then
begin
Bitmap := Pdf.RenderPage(0, 0, W, H, ExtraRotation, Opts);
if Bitmap <> nil then // 頁面尺寸退化時為 nil
try
BlitBitmap(Printer.Canvas.Handle, Bitmap, X, Y);
finally
Bitmap.Free;
end;
end
else
Pdf.RenderPage(Printer.Canvas.Handle, X, Y, W, H, ExtraRotation, Opts);
Application.ProcessMessages; // 讓取消按鈕有機會設旗標
if Cancel then
Break;
end;
Result := not Cancel;
finally
if Result then
Printer.EndDoc // 把工作送進排隊器
else
Printer.Abort; // 取消或例外:什麼都不印
end;
end;
檢視器呼叫它時把 Pdf.FormFill 當 FormLayer 傳進去;扁平化副本傳 False。EndDoc 是提交,不是收尾步驟。BeginDoc 之後,Printer.EndDoc 會把到目前為止畫好的東西當成一份有效工作交給排隊器,所以在 finally 裡無條件呼叫 EndDoc,會把一次取消的列印變成印到一半的列印。這段常式改在 finally 裡依成功旗標判斷:最後一頁之後走 EndDoc,取消或例外之後走 Abort。迴圈的跳脫也要同樣小心,因為 Delphi 與 Free Pascal 都只在進入時求一次 for 迴圈的結束值。從取消處理常式把 ToPage 或份數設成零,停不住已經在跑的迴圈;請測旗標然後 Break,遇到份數與排序的巢狀迴圈就逐層跳脫。還有,Application.ProcessMessages 也讓選單快速鍵有機會觸發,所以工作進行中請把開啟、儲存與列印停用
讓點陣路徑待在記憶體範圍內
TPdf.RenderPageBanded 把一頁切成您指定高度的一條條橫帶算圖,每條交給回呼處理。每條都是整頁的切片——不做任何縮放——而且帶著跟整頁 bitmap 相同的欄位值。用 512 列的帶,一頁寬 4960 像素只需要每帶約 10 MB,而不是整張紙的 139 MB,大概十四分之一。TPdfBandCallback 是方法指標,收到帶索引、該帶在頁面像素裡的頂端偏移與 bitmap,回傳 True 繼續;RenderPageBanded 回傳帶數,失敗時為 0
type
TBandPrinter = class
private
FDC: HDC;
FLeft, FTop: Integer;
public
constructor Create(DC: HDC; Left, Top: Integer);
function PrintBand(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean;
end;
constructor TBandPrinter.Create(DC: HDC; Left, Top: Integer);
begin
inherited Create;
FDC := DC;
FLeft := Left;
FTop := Top;
end;
function TBandPrinter.PrintBand(BandIndex, BandTopY: Integer;
Bitmap: TBitmap): Boolean;
begin
BlitBitmap(FDC, Bitmap, FLeft, FTop + BandTopY);
Result := True; // False 會停掉剩下的帶
end;
// 在頁面迴圈裡代替整頁 bitmap 分支呼叫
procedure PrintPageBanded(Pdf: TPdf; Page, X, Y, W, H: Integer;
ExtraRotation: TRotation);
var
Bands: TBandPrinter;
begin
Bands := TBandPrinter.Create(Printer.Canvas.Handle, X, Y);
try
if Pdf.RenderPageBanded(W, H, 512, Bands.PrintBand, ExtraRotation,
[reAnnotations, rePrinting]) = 0 then
raise Exception.CreateFmt('Page %d could not be rendered', [Page]);
finally
Bands.Free;
end;
end;
ViewerPreferences 與權限位元,宿主該怎麼處理?
讀出來,在您自己的程式碼裡照著做;PDFium 只負責報告,頁面照畫不誤。產生器可以把列印提示存進 catalog 的 /ViewerPreferences 字典(ISO 32000-1 §12.2,表 150),TPdf 把會影響列印對話框的那幾個攤出來:
PrintScaling(Boolean)在檔案寫/PrintScaling /None時為 False,意思是依實際大小列印;/AppDefault或缺這個項目時為 True。標準要求即使沒顯示列印對話框也要遵守PrintPaperHandling(TPrintPaperHandling)把/Duplex帶成phSimplex、phDuplexFlipShortEdge、phDuplexFlipLongEdge或phUndefined。SetPdfPrintPaperHandlingDevMode把它寫進DEVMODE.dmDuplex並設上DM_DUPLEX;遇到phUndefined則回傳 False 且不動那個記錄,讓驅動程式的預設值留著PrintCopies(Integer)就是/NumCopies,要放進對話框的份數
權限位元遵循同一條規則。對標準安全處理器,ISO 32000-1 §7.6.3.2(表 22)把 /P 的位元 3 分給列印,而對 revision 3 及以後的處理器,位元 12 分給全保真列印;位元 3 設立而位元 12 未設立時,列印僅限於外觀的低階呈現,可能已經失真。TPdf.GrantedPermissions 把遮罩解成 permPrint 與 permPrintHighQuality,HasPermission 測單一個值;引擎不論您要什麼都照畫,所以執行是宿主的責任,通常就是停用列印按鈕。只授予 permPrint 時,符合標準的讀法是走中等解析度的 bitmap 路徑,而不是全授權才允許的向量或 PostScript 輸出。這些位元描述的是開啟時的文件:用擁有者密碼解鎖的檔案回報完整存取權,而 UserHasPermission 只回答使用者層級的位元
uses
Winapi.Windows, System.SysUtils, Vcl.Dialogs, Vcl.Printers, PDFium;
procedure PreparePrintJob(Pdf: TPdf; Dialog: TPrintDialog;
out ActualSize, HighQuality: Boolean);
var
Device, Driver, Port: array[0..MAX_PATH] of Char;
DevModeHandle: THandle;
DevMode: PDeviceMode;
begin
if not Pdf.HasPermission(permPrint) then
raise Exception.Create('This document does not permit printing');
HighQuality := Pdf.HasPermission(permPrintHighQuality);
ActualSize := not Pdf.PrintScaling; // /PrintScaling /None
Printer.GetPrinter(Device, Driver, Port, DevModeHandle);
if DevModeHandle <> 0 then
begin
DevMode := GlobalLock(DevModeHandle);
try
SetPdfPrintPaperHandlingDevMode(DevMode^, Pdf.PrintPaperHandling);
finally
GlobalUnlock(DevModeHandle);
end;
Printer.SetPrinter(Device, Driver, Port, DevModeHandle); // 對話框從這裡的設定開始
end;
Dialog.MinPage := 1;
Dialog.MaxPage := Pdf.PageCount;
Dialog.Options := Dialog.Options + [poPageNums];
if Pdf.PrintCopies > 1 then
Dialog.Copies := Pdf.PrintCopies;
end;
// ActualSize 為 True 時取代 FitRect
procedure ActualSizeRect(Pdf: TPdf; out X, Y, W, H: Integer);
begin
// PDF 這邊每英寸 72 points,印表機這邊每英寸一個裝置像素
W := Round(Pdf.PageWidth / 72 * GetDeviceCaps(Printer.Handle, LOGPIXELSX));
H := Round(Pdf.PageHeight / 72 * GetDeviceCaps(Printer.Handle, LOGPIXELSY));
X := (Printer.PageWidth - W) div 2; // 頁面比可列印區域大時為負值
Y := (Printer.PageHeight - H) div 2; // 邊緣會被裁掉
end;
在真正的列印邊界上跑 will-print 與 did-print 動作
文件層級的列印腳本,就在宿主執行它們的時候跑。catalog 的 additional-actions 字典(ISO 32000-1 §12.6.3,表 197)定義了 WP,列印前執行的 JavaScript 動作,以及 DP,列印後執行。PDFium Component 透過 ExecuteDocumentLifecycleAction(dlaWillPrint, ErrorMessage) 與 dlaDidPrint 暴露兩者,並把它們擋在算圖呼叫之外:縮圖、快照與轉檔也在算圖,對文件腳本來說,它們沒有一個該長得像使用者的列印。順序是您要弄對的地方。會在紙上蓋日期或填「列印者」欄位的 will-print 腳本會改變表單值,所以要在第一頁算圖之前跑,走扁平化路線時則要在副本存檔之前跑;did-print 只該在 EndDoc 提交工作之後才觸發。表單環境不存在,或 LifecycleActionPolicy 拒絕時,呼叫會回傳 False 並附訊息;面對不信任的檔案,用 lapDisabled 把文件動作整個關掉。更完整的腳本全貌見搭配宿主事件執行 AcroForm JavaScript
在 Windows 上,一份必須帶著表單值的工作屬於 bitmap 分支:一般解析度把 RenderPage 畫進 TBitmap,600 dpi 以上用 RenderPageBanded。PDFium Component 的裝置內容多載就留給扁平化副本,以及沒有欄位的文件——那種情況下它的向量輸出才是更好的結果