技術記事

PDF フォームの値を印刷し、ページ比率を保つ

Delphi のビューアでユーザーが PDF フォームを記入し、Print を押すと、出てきた紙は全フィールドが空欄で、Letter サイズのページが A4 に合わせて引き伸ばされています。どちらの故障も印刷ループの中にあります。PDFium Component はフォームの値をビットマップにだけ描画し(あるいはフラット化したコピーに焼き込み)、各ページはアスペクト比を保つただ 1 つの倍率を求めています

フォームレイヤーはビットマップを要る第 2 パス

Windows では、デバイスコンテキストのオーバーロード TPdf.RenderPage(DC, Left, Top, Width, Height, Rotation, Options) がプリンターの HDC をそのまま FPDF_RenderPage へ渡します。この呼び出しはページのコンテンツを描き、reAnnotations が設定されていればマークアップ注釈も描きます。インタラクティブなフォームレイヤーは DC には決して届きません。PDFium の form-fill 環境に属し、FPDF_FFLDraw が描くもので、その唯一のターゲットは FPDF_BITMAP です。HDC 版は存在しません。TPdf.RenderPage の 2 つのビットマップオーバーロードは、文書が FormFill を True(既定)にして開かれていれば、同じピクセルの上で FPDF_RenderPageBitmap と FPDF_FFLDraw を走らせます。オプションに rePrinting があると、PDFium Component はフォームパスの前にフィールドのハイライトを取り除き、後で既定のハイライト色とアルファを復元します。編集可能フィールドを囲む着色ボックスは画面に残るわけです。rePrinting はエンジンの FPDF_PRINTING フラグにも対応し、出力を印刷として印を付けます。ISO 32000-1 §12.5.3(Table 165)が注釈フラグのビット 3 で扱うのがこのケースです。適合リーダーは、Print ビットの立っていない注釈を、画面でどう見えていようと紙から外します

残りはプラットフォームが決めます。PDFium エンジンでは、FPDF_RenderPage は Windows GDI を通してデバイスコンテキストへ描き、Windows ビルドにしか存在しません。FPDF_FFLDraw はビットマップ以外の何ものにも描かず、どのプラットフォームでも動きます。Linux や他の非 Windows ターゲットでは GDI の呼び出しがそもそもないので、HDC オーバーロードはビットマップの描画——フォームレイヤー込み——で賄われ、ハンドルの背後のキャンバスへ転写されます。したがってデバイスコンテキストの呼び出しは、Windows の外ではフォームの値を運び、Windows の上では取り残します。Delphi と Lazarus で共有するコードでは、分岐を明示的に保ちましょう。文書がフォーム環境を持ち、その値が紙に載るべきなら、どのプラットフォームでもビットマップを要求します

Delphi での PDFium Component のフォームフィールド印刷。プリンター HDC 上の TPdf.RenderPage はフォームレイヤーなしで FPDF_RenderPage を呼び、TBitmap のオーバーロードは StretchDIBits の前に FPDF_FFLDraw のパスを足す
Windows では RenderPage のビットマップオーバーロードだけが FPDF_FFLDraw のフォームパスを走らせます。打ち込んだフィールドの値がこの経路では紙に届き、デバイスコンテキストの経路では消える理由です

印刷ジョブはどの経路を取るべきか

紙が何を運ぶべきかで選びます。生のフォームの値ならビットマップ経路、ベクター出力なら HDC 経路、そしてフラット化コピーなら、保存と再読み込み 1 回の値段で両方が手に入ります。SetPdfPrintMode はライブラリ全体に効くので、変えるのはジョブの間であって、ページの間ではありません

経路紙に載るフォームの値出力コスト
RenderPage(DC, …)Windows では載らないベクター GDI。SetPdfPrintMode が EMF(既定)か PostScript レベルを選ぶスプール最小、最速
TBitmap への RenderPage、続いて StretchDIBits載る。未保存の編集も含む要求したピクセルサイズのラスタ1 ページあたり 幅 × 高さ × 4 バイト、DIB コピーも加算
RenderPageBanded載るラスタ。1 度に 1 ストリップ全シートの代わりに 1 バンドだけメモリに
フラット化コピー、続いて RenderPage(DC, …)載る。焼き込まれたページコンテンツとしてベクター保存、再読み込み、GenerateFormAppearances、FlattenAllPages(FLAT_PRINT)

600 dpi の A4 全ページは約 4960 × 7016 のデバイスピクセルで、32 ビット/ピクセルでは 1 ページ 139,197,440 バイトになります。StretchDIBits のために DIB へコピーすればピークは倍になり、32 ビットプロセスには重い荷です。それでもビューアの Print ボタンの既定はビットマップ経路が正解です。フィールドに打ち込んで Print を押したユーザーは、その値が紙に現れることを期待しますし、バンディングがメモリを有界に保ちます。アーカイブされた明細書のようにアプリケーションの外へ出ていく文書には、フラット化コピーのほうが合っています。値は普通のページコンテンツになり、プリンターはベクター出力を受け取ります

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 を非 active のままにする
      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 の appearance stream(ISO 32000-1 §12.5.5)をページコンテンツへ焼き込みますが、appearance stream は最後の作成者が書いたものそのままです。/V のフィールド値は関与しません。appearance を一度も再生成しなかったツールが記入したフォームは、古いテキストか、何もない状態にフラット化されます。だから GenerateFormAppearances が先に走ります。現在の値からテキストフィールドの appearance を再構築し、チェックボックスとラジオの /AS 状態を同期します。そのためには文書を開いた時点で FormFill が True であったことが要ります。これらはすべてコピーの上でやりましょう。フラット化は実行対象の TPdf のページを書き換え、開かれたビューアは自分のフィールドが編集可能なままでいることを望みますから。コピーは、下の印刷ルーチンのベクター分岐をフォームレイヤー切りのまま通ります。ジョブが終わったら解放し、一時ファイルを削除します

Delphi での PDFium Component のフラット化印刷コピー。一時ファイルへの SaveAs、FormFill 付きで再オープン、GenerateFormAppearances、FlattenAllPages(FLAT_PRINT)、その後デバイスコンテキスト上の RenderPage によるベクター印刷
FlattenAllPages の前に GenerateFormAppearances を呼べば、焼き込まれる appearance stream は、前の作成者が /AP に残したものではなく現在のフィールド値と一致します

各ページを印刷可能領域へ収める

倍率は 2 つの係数の小さいほうで 1 回だけ掛け、結果を中央へ置きます。Printer.PageWidth × Printer.PageHeight への引き伸ばしは、紙と形の違うページをすべて歪めます。A4 に載る Letter のページ(612 × 792 ポイント)、縦のシートに載る横のページです。プリンター側はデバイスピクセルでの印刷可能領域(HORZRES と VERTRES)で、ドライバーのマージンはすでに除外済みです。PDF 側では、TPdf.PageWidth と PageHeight は表示どおりのページをすでに報告します。/Rotate 90 付きで格納された A4 縦のページは 842 × 595 ポイントで返ってきます。ISO 32000-1 §7.7.3.3(Table 30)は、表示や印刷のときページを /Rotate どおりに回すべきと言い、PDFium はそれを render 呼び出しの内側で適用します。RenderPage の Rotation 引数は、ユーザーがビューを回したときにビューアが足す回転だけです。そこへページ自身の PageRotation を渡すと、ページは 2 回回ります。幅と高さの入れ替えは、その追加回転が 90 度か 270 度のときだけです。回転下でのサムネイルの収め方とズームを支配するのと同じ 2 つの規則です。下のルーチンは、PDFium Component での PDF 文書の印刷の基本ループに、フォーム値の分岐と、ジョブを捨てるキャンセル経路を足したものです

Delphi 印刷における PDFium Component のページ収め。Letter ページを Printer.PageWidth と PageHeight に引き伸ばすやり方と、アスペクト比を保つ 1 つの倍率で印刷可能領域の中央に置くやり方の対比。/Rotate は TPdf.PageWidth にすでに反映済み
2 つの係数の小さいほうで拡大縮小すれば、すべてのページは無歪みで中央に保たれます。幅と高さの入れ替えは、ビューアの追加回転が 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;     // Cancel ボタンがフラグを立てられるようにする
      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 ループの終値は入場時に 1 回だけ評価します。Cancel ハンドラから ToPage や部数を 0 にしても、すでに走っているループは止まりません。フラグを試して Break しましょう。部数と照合のネストしたループでは、各段を抜けます。さらに Application.ProcessMessages はメニューショートカットまで発火させるので、ジョブの実行中は Open、Save、Print を無効にしましょう

ラスタ経路をメモリの中に収める

TPdf.RenderPageBanded はページを、好きな高さの横帯に分けて描画し、各帯をコールバックへ渡します。各帯はフルサイズページの一片で、再スケールは何もなく、フルページのビットマップと同じフォームの値を運びます。512 行の帯なら、幅 4960 ピクセルのページはシート全体の 139 MB の代わりに、帯 1 本につき約 10 MB で済みます。およそ 14 分の 1 のメモリです。TPdfBandCallback はメソッドポインタで、帯インデックス、ページピクセルでの帯の上端オフセット、ビットマップを受け取り、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;

// 全ページビットマップ分岐の代わりにページループから呼ばれる
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、Table 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(Table 22)が /P のビット 3 を印刷に、revision 3 以降のハンドラではビット 12 をフルフィデリティの印刷に割り当てます。ビット 3 が立ってビット 12 が 0 なら、印刷は appearance の低水準の表現に限られ、劣化していることもあります。TPdf.GrantedPermissions はマスクを permPrint と permPrintHighQuality へ解読し、HasPermission が 1 つの値を試します。エンジンは要求されたものを何でも描くので、強制はホストの仕事で、たいていは無効化された Print ボタンです。permPrint だけが与えられているとき、規格に合う読みは、全許可が開放するベクターや PostScript 出力ではなく、控えめな解像度のビットマップ経路です。ビットは開いたときの文書を記述します。owner パスワードで解錠されたファイルはフルアクセスを報告し、UserHasPermission は user レベルのビットだけに答えます

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 側は 1 インチ 72 ポイント、プリンターは 1 インチあたりのデバイスピクセル
  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、Table 197)は、印刷の前に実行される JavaScript アクションの WP と、後の DP を定義します。PDFium Component は両方を ExecuteDocumentLifecycleAction(dlaWillPrint, ErrorMessage) と dlaDidPrint で公開し、render 呼び出しからは遠ざけます。サムネイル、スナップショット、変換も描画しますが、どれも文書スクリプトにはユーザーの印刷に見えてはいけないからです。順序を正すのはあなたの仕事です。日付を押したり「printed by」フィールドを埋めたりする will-print スクリプトはフォームの値を変えます。最初のページが描画される前に走らせ、フラット化経路ではコピーを保存する前に。did-print は EndDoc がジョブをコミットした後だけ発火させましょう。フォーム環境がなければ、あるいは LifecycleActionPolicy が拒めば、呼び出しはメッセージ付きで False を返します。信頼できないファイルには lapDisabled が文書アクションを切ります。スクリプトの全体像はホストイベント付きの AcroForm JavaScript 実行にあります

Windows では、フォームの値を運ぶべきジョブはビットマップ分岐に属します。通常の解像度では TBitmap への RenderPage、600 dpi 以上では RenderPageBanded です。PDFium Component のデバイスコンテキストオーバーロードは、フラット化コピーと、フィールドのない文書のために取っておきましょう。そちらではベクター出力のほうが良い結果になります