技術文章

列印 PDF 表單值並保持頁面比例

使用者在 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

Delphi 中 PDFium Component 列印表單欄位:對印表機 HDC 呼叫 TPdf.RenderPage 只走 FPDF_RenderPage、不含表單層,而 TBitmap 多載會在 StretchDIBits 之前多跑一遍 FPDF_FFLDraw
在 Windows 上只有 bitmap 多載的 RenderPage 會跑 FPDF_FFLDraw 表單這一遍,所以使用者輸入的欄位值走那條路才印得上紙,走裝置內容那條就消失

列印工作該走哪條路?

看紙上必須帶什麼來選。表單活動值指向 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 的頁面,而開著的檢視器希望欄位保持可編輯。副本接著走下面列印常式的向量分支,並把表單層關掉;工作結束後釋放它、刪掉暫存檔

Delphi 中 PDFium Component 的扁平化列印副本:SaveAs 到暫存檔,以 FormFill 重開,GenerateFormAppearances、FlattenAllPages(FLAT_PRINT),再透過裝置內容上的 RenderPage 做向量列印
在 FlattenAllPages 之前先呼叫 GenerateFormAppearances,烤進去的外觀串流才會對上目前的欄位值,而不是上一個產生器留在 /AP 裡的東西

把每一頁貼進可列印區域

只縮放一次,取兩個係數中比較小的那個,然後置中。直接拉寬到 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 文件裡的基礎迴圈加上表單值分支,以及一條會丟棄整份工作的取消路徑

Delphi 列印時 PDFium Component 的頁面貼合:把 Letter 頁硬拉成 Printer.PageWidth 與 PageHeight,對比保持長寬比、在可列印區域內置中的縮放,而 /Rotate 已經反映在 TPdf.PageWidth 裡
以兩個係數中較小的那個縮放,每頁都不變形且置中,而寬高只在檢視器多加 ro90 或 ro270 旋轉時才交換
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 的裝置內容多載就留給扁平化副本,以及沒有欄位的文件——那種情況下它的向量輸出才是更好的結果