기술 문서

PDF 폼 값 인쇄와 페이지 비율 지키기

사용자가 Delphi 뷰어에서 PDF 폼을 채우고 인쇄를 누르면, 종이에는 모든 필드가 비어 있고 Letter 크기 페이지가 A4를 채우려고 늘어나서 나옵니다. 두 결함 모두 인쇄 루프 안에 삽니다. PDFium Component는 필드 값을 비트맵에만 그리고(또는 평탄화된 사본에 구워 넣고), 각 페이지는 종횡비를 지키는 스케일 인자 하나를 원합니다

폼 계층은 비트맵을 필요로 하는 두 번째 패스입니다

Windows에서 디바이스 컨텍스트 오버로드 TPdf.RenderPage(DC, Left, Top, Width, Height, Rotation, Options)는 프린터의 HDC를 FPDF_RenderPage에 곧장 넘깁니다. 그 호출은 페이지 콘텐츠를 그리고 reAnnotations가 set되어 있으면 마크업 주석도 그립니다. 인터랙티브 폼 계층은 DC에는 결코 닿지 않습니다. 그것은 PDFium의 폼 채우기 환경에 속하며 FPDF_FFLDraw가 그리는데, 그 유일한 대상은 FPDF_BITMAP입니다. HDC 변형은 없습니다. 문서가 기본값인 FormFill True로 열렸을 때 TPdf.RenderPage의 두 비트맵 오버로드는 같은 픽셀 위에서 FPDF_RenderPageBitmap과 FPDF_FFLDraw를 돌립니다. 옵션에 rePrinting이 있으면 PDFium Component는 폼 패스 전에 필드 하이라이트를 걷어냈다가 그 뒤 기본 하이라이트 색과 알파를 되살리므로, 편집 가능한 필드 주위의 물든 상자들은 화면에 남습니다. rePrinting은 엔진의 FPDF_PRINTING 플래그에도 대응하며, 그것은 출력을 인쇄로 표시합니다 — ISO 32000-1 §12.5.3(Table 165)이 주석 플래그 비트 3으로 다루는 사례입니다. 표준을 따르는 reader는 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 경로를 가리키며, 평탄화된 사본은 저장과 재로드의 값으로 둘 다를 줍니다. SetPdfPrintMode는 라이브러리 전역이므로 작업 사이에서는 바꾸되 페이지 사이에서는 절대 바꾸지 마세요

경로종이에 찍히는 폼 값출력비용
RenderPage(DC, …)아니요, Windows에서벡터 GDI. SetPdfPrintMode가 EMF(기본값)나 PostScript 레벨을 고름가장 작은 스풀, 가장 빠름
TBitmap으로 RenderPage 후 StretchDIBits예, 저장하지 않은 편집 포함요청한 픽셀 크기의 래스터페이지마다 폭 × 높이 × 4바이트, DIB 사본 추가
RenderPageBanded예래스터, 한 번에 한 스트립통째 시트 대신 메모리에는 밴드 하나
평탄화 사본 후 RenderPage(DC, …)예, 구워진 페이지 콘텐츠로벡터저장, 다시 로드, GenerateFormAppearances, FlattenAllPages(FLAT_PRINT)

600 dpi의 A4 시트 한 장은 대략 4960 × 7016 장치 픽셀이고 32비트 픽셀당 한 페이지에 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를 비활성으로 둡니다
      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는 각 위젯의 appearance stream(ISO 32000-1 §12.5.5)을 페이지 콘텐츠에 굽는데, appearance stream은 마지막 producer가 쓴 그대로이며 /V의 필드 값은 아무 역할도 하지 않습니다. appearance를 재생성한 적 없는 도구가 채운 폼은 오래된 텍스트나 아무것도 없는 것으로 평탄화됩니다. 그래서 GenerateFormAppearances가 먼저 돕니다. 현재 값에서 텍스트 필드 appearance를 다시 빚고 체크박스와 라디오 /AS 상태를 동기화하며, 문서가 열릴 때 FormFill이 True였기를 요구합니다. 이 모든 것을 사본에서 하세요. 평탄화는 자기가 도는 TPdf의 페이지를 다시 쓰고, 열려 있는 뷰어는 자기 필드가 편집 가능한 채로 남기를 원합니다. 사본은 아래 인쇄 루틴의 벡터 분기를 폼 계층 끈 채로 통과하며, 작업이 끝나면 해제하고 임시 파일을 지웁니다

Delphi의 PDFium Component 평탄화 인쇄 사본: 임시 파일로 SaveAs, FormFill로 재열기, GenerateFormAppearances, FlattenAllPages(FLAT_PRINT), 그다음 디바이스 컨텍스트의 RenderPage로 벡터 인쇄
FlattenAllPages 전에 GenerateFormAppearances를 부르면 구워지는 appearance stream이 마지막 producer가 /AP에 남긴 것 대신 현재 필드 값과 맞습니다

각 페이지를 인쇄 가능 영역에 맞추기

두 인자 중 작은 쪽으로 한 번 스케일하고 결과를 중앙에 두세요. 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은 렌더 호출 안에서 그것을 적용하며, RenderPage의 Rotation 인자는 사용자가 뷰를 회전할 때 뷰어가 얹는 추가 회전일 뿐입니다. 페이지 자신의 PageRotation을 거기 넘기면 페이지는 두 번 돕니다. 폭과 높이를 맞바꾸는 것은 그 추가 회전이 90도나 270도일 때만이며, 회전 아래 썸네일 맞춤과 줌을 지배하는 바로 그 규칙 쌍입니다. 아래 루틴은 PDFium Component로 PDF 문서 인쇄하기의 기본 루프를 폼 값 분기와 작업을 버리는 취소 경로로 확장합니다

Delphi 인쇄를 위한 PDFium Component 페이지 맞춤: Printer.PageWidth와 PageHeight로 늘어난 Letter 페이지 versus 인쇄 가능 영역 중앙의 종횡비 유지 스케일 하나, /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;     // Cancel 버튼이 플래그를 set할 수 있게 합니다
      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 루프의 끝 값을 들어갈 때 한 번 평가합니다. Cancel 핸들러에서 ToPage나 사본 수를 0으로 만드는 것은 이미 돌고 있는 루프를 멈추지 못합니다. 플래그를 검사하고 Break하세요. 중첩된 사본과 콜레이션 루프에서는 각 단계를 빠져 나오세요. 그리고 Application.ProcessMessages는 메뉴 단축키도 불려 들이므로 작업이 도는 동안 열기, 저장, 인쇄를 비활성화하세요

래스터 경로를 메모리 안에 묶어 두기

TPdf.RenderPageBanded는 페이지를 당신이 고른 높이의 가로 스트립으로 렌더링해 각 스트립을 콜백에 넘깁니다. 각 스트립은 전체 크기 페이지의 조각이며 아무것도 재스케일하지 않고 전체 페이지 비트맵과 같은 필드 값을 나릅니다. 512행 밴드로는 4960픽셀 너비의 페이지가 시트의 139 MB 대신 밴드당 약 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은 그것들을 보고하며 페이지는 어차피 렌더링합니다. producer는 카탈로그의 /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를 set합니다. phUndefined에는 False를 반환하고 레코드를 그대로 두므로 드라이버 기본값이 살아남습니다
  • PrintCopies(Integer)는 /NumCopies, 대화상자에 넣을 사본 수입니다

권한 비트도 같은 규칙을 따릅니다. 표준 보안 핸들러에 대해 ISO 32000-1 §7.6.3.2(Table 22)는 /P의 비트 3을 인쇄에 할당하고, 리비전 3 이상 핸들러에서는 비트 12를 전체 충실도 인쇄에 할당합니다. 비트 3이 set되고 비트 12가 꺼져 있으면 인쇄는 appearance의 낮은 수준 표현, 어쩌면 저하된 것으로 제한됩니다. TPdf.GrantedPermissions는 마스크를 permPrint와 permPrintHighQuality로 디코딩하고 HasPermission은 값 하나를 검사합니다. 엔진은 요청하는 무엇이든 렌더링하므로 집행은 호스트의 몫이며, 보통은 비활성화된 Print 버튼입니다. permPrint만 부여되었을 때 표준에 맞는 읽기는 전체 부여가 허용하는 벡터나 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포인트, 프린터는 인치당 장치 픽셀
  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 액션을 진짜 인쇄 경계에서 돌리기

문서 수준 인쇄 스크립트는 호스트가 돌릴 때 돕니다. 카탈로그의 additional-actions 사전(ISO 32000-1 §12.6.3, Table 197)은 인쇄 전에 수행되는 JavaScript 액션 WP와 인쇄 후에 수행되는 DP를 정의합니다. PDFium Component는 둘을 ExecuteDocumentLifecycleAction(dlaWillPrint, ErrorMessage)과 dlaDidPrint로 드러내고 렌더 호출에서는 그것들을 뺍니다. 썸네일, 스냅샷, 변환도 렌더링하며 그중 어느 것도 문서 스크립트에게 사용자의 인쇄처럼 보여서는 안 됩니다. 순서는 당신이 바르게 잡을 몫입니다. 날짜를 찍거나 "printed by" 필드를 채우는 will-print 스크립트는 폼 값을 바꾸므로 첫 페이지가 렌더링되기 전에, 평탄화 경로에서는 사본이 저장되기 전에 돌리세요. did-print는 EndDoc가 작업을 넘긴 뒤에만 발화합니다. 폼 환경이 없거나 LifecycleActionPolicy가 거부하면 호출은 메시지와 함께 False를 반환합니다. 신뢰할 수 없는 파일에는 lapDisabled가 문서 액션을 끕니다. 더 넓은 스크립팅 그림은 호스트 이벤트와 함께 AcroForm JavaScript 돌리기에 있습니다

Windows에서 폼 값을 나라야 하는 작업은 비트맵 분기에 옵니다. 평범한 해상도는 TBitmap으로 RenderPage, 600 dpi 이상은 RenderPageBanded. PDFium Component의 디바이스 컨텍스트 오버로드는 평탄화된 사본과 필드 없는 문서를 위해 남겨 두세요. 그곳에서는 벡터 출력이 더 나은 결과입니다