Техническая статья

Печатаем значения PDF-форм и держим пропорции страниц

Пользователь заполняет PDF-форму в Delphi-viewer, жмёт Print — и лист выходит из принтера со всеми полями пустыми и страницей формата Letter, растянутой до полного A4. Обе болячки сидят в печатном цикле: PDFium Component рисует значения полей только в битмап (или запекает их во флэттен-копию), а каждой странице нужен один коэффициент масштаба с сохранением пропорций

Слой форм — второй проход, которому нужен битмап

На Windows перегрузка для контекста устройства TPdf.RenderPage(DC, Left, Top, Width, Height, Rotation, Options) отдаёт HDC принтера прямо в FPDF_RenderPage. Тот вызов рисует содержимое страницы плюс разметочные аннотации, когда взведён reAnnotations. Интерактивный слой форм до DC не доходит — он принадлежит form-fill-окружению PDFium и рисуется FPDF_FFLDraw, у которого единственная цель — FPDF_BITMAP. HDC-варианта нет. Два битмапных перегруза TPdf.RenderPage гонят FPDF_RenderPageBitmap и FPDF_FFLDraw по одним и тем же пикселям, когда документ открыт с FormFill в True — это дефолт. С 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: когда у документа есть форм-окружение и его значения должны попасть на бумагу, запрашивайте битмап на любой платформе

Печать полей форм в PDFium Component на Delphi: TPdf.RenderPage на HDC принтера зовёт FPDF_RenderPage без слоя форм, а перегруз с TBitmap добавляет проход FPDF_FFLDraw перед StretchDIBits
На Windows только битмапный перегруз RenderPage запускает форм-проход FPDF_FFLDraw, поэтому набранные значения полей доезжают до бумаги этим маршрутом и пропадают на маршруте через контекст устройства

Каким маршрутом идти печатному заданию?

Выбирайте по тому, что должно оказаться на бумаге. Живые значения форм указывают на битмапный путь, векторный вывод — на путь 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 байт на одну страницу; копирование в DIB ради StretchDIBits удваивает пик, тяжёлая ноша для 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;          // пересобрать /AP из текущих /V
    if not Result.FlattenAllPages(FLAT_PRINT) then
      raise Exception.Create('Flattening failed');
  except
    Result.Free;
    TFile.Delete(TempName);
    raise;
  end;
end;

У флэттен-маршрута есть правило порядка, которое важнее флага. FlattenAllPages запекает поток внешнего вида каждого виджета (ISO 32000-1 §12.5.5) в контент страницы, а поток внешнего вида — это что угодно из написанного последним производителем; значение поля в /V роли не играет. Форма, заполненная инструментом, который так и не перегенерировал свои внешние виды, флэттенится в старый текст или в пустоту, поэтому первым бежит GenerateFormAppearances: он пересобирает внешние виды текстовых полей из текущих значений и синхронизирует состояния /AS чекбоксов и радиокнопок, а для работы ему нужно, чтобы при открытии документа FormFill был True. Делайте всё это на копии: флэттенинг переписывает страницы того TPdf, на котором запущен, а открытому viewer'у нужны редактируемые поля. Копия идёт через векторную ветку печатной процедуры ниже со слоем форм выключенным; освободите её и удалите временный файл, когда задание закончится

Флэттен-копия для печати в PDFium Component на Delphi: SaveAs во временный файл, переоткрытие с FormFill, GenerateFormAppearances, FlattenAllPages(FLAT_PRINT), затем векторная печать через RenderPage на контексте устройства
Вызов GenerateFormAppearances до FlattenAllPages заставляет запечённые потоки внешнего вида совпадать с текущими значениями полей, а не с тем, что оставил в /AP прошлый производитель

Вписываем каждую страницу в печатную область

Масштабируйте один раз — по меньшему из двух коэффициентов — и центрируйте результат. Растягивание в Printer.PageWidth × Printer.PageHeight искажает каждую страницу, чья форма отличается от бумаги: Letter (612 × 792 пунктов) на A4 или альбомная страница на портретном листе. Со стороны принтера это печатная область в пикселях устройства (HORZRES на VERTRES), уже без полей драйвера. Со стороны PDF TPdf.PageWidth и PageHeight уже отчитывают страницу так, как она показывается: портретный A4, сохранённый с /Rotate 90, возвращается как 842 × 595 пунктов. ISO 32000-1 §7.7.3.3 (Table 30) велит поворачивать страницу по /Rotate при показе и печати, PDFium применяет его внутри вызова рендера, а аргумент Rotation у RenderPage — только дополнительный поворот, который viewer добавляет, когда пользователь вращает вид. Передадите туда собственный PageRotation страницы — и страница повернётся дважды. Меняйте ширину с высотой местами для вписывания только когда этот дополнительный поворот — 90 или 270 градусов, та же пара правил, что правит вписыванием миниатюр и зумом при повороте; процедура ниже расширяет базовый цикл печати PDF-документов с PDFium Component веткой значений форм и путём отмены, выбрасывающим задание

Вписывание страницы при печати в PDFium Component на Delphi: страница Letter, растянутая в Printer.PageWidth и PageHeight, против одного масштаба с сохранением пропорций, отцентрованного в печатной области, при /Rotate, уже учтённом в TPdf.PageWidth
Масштаб по меньшему из двух коэффициентов держит каждую страницу неискажённой и по центру, а ширина с высотой меняются местами лишь при дополнительном ro90 или ro270 от viewer
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;

Viewer вызывает это с Pdf.FormFill в роли FormLayer; флэттен-копия передаёт False. EndDoc — коммит, а не шаг уборки. После BeginDoc Printer.EndDoc отдаёт спулеру всё отрендеренное к этому моменту как валидное задание, поэтому безусловный EndDoc в finally превращает отменённую печать в полупечатную. Процедура решает в finally по флагу успеха: EndDoc после последней страницы, Abort после отмены или исключения. Выход из цикла требует той же аккуратности: Delphi и Free Pascal оба вычисляют конечное значение for-цикла один раз, на входе. Обнуление ToPage или счётчика копий из обработчика Cancel не остановит уже бегущий цикл; проверяйте флаг и делайте Break, а с вложенными циклами копий и раскладок выходите из каждого уровня. И поскольку Application.ProcessMessages позволяет сработать и горячим клавишам меню, гасите Open, Save и Print, пока задание бежит

Держим растровый путь в пределах памяти

TPdf.RenderPageBanded рендерит страницу горизонтальными полосами выбранной вами высоты и передаёт каждую полосу в callback. Каждая полоса — срез полноразмерной страницы, ничего не перемасштабируется, и несёт те же значения полей, что и полностраничный битмап. Полосами по 512 строк страница шириной 4960 пикселей требует около 10 МБ на полосу вместо 139 МБ на лист — примерно одна четырнадцатая памяти. 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 о них сообщает и рисует страницу независимо. Производитель может сложить печатные подсказки в словарь /ViewerPreferences каталога (ISO 32000-1 §12.2, Table 150), и TPdf выставляет наружу те, что формируют диалог печати:

  • PrintScaling (Boolean) равен False, когда файл говорит /PrintScaling /None, что значит печатать в натуральную величину, и True при /AppDefault или отсутствующей записи. Стандарт хочет, чтобы это уважалось, даже если диалог печати не показывается
  • PrintPaperHandling (TPrintPaperHandling) несёт /Duplex как phSimplex, phDuplexFlipShortEdge, phDuplexFlipLongEdge или phUndefined. SetPdfPrintPaperHandlingDevMode записывает его в DEVMODE.dmDuplex и ставит DM_DUPLEX; для phUndefined возвращает False и оставляет запись в покое, так что дефолт драйвера выживает
  • PrintCopies (Integer) — это /NumCopies, число копий для диалога

Биты разрешений следуют тому же правилу. Для стандартного security handler ISO 32000-1 §7.6.3.2 (Table 22) назначает бит 3 /P печати, а для обработчиков revision 3 и старше бит 12 — печати в полной точности; при взведённом бите 3 и сброшенном 12 печать ограничена низкоуровневым представлением внешнего вида, возможно, с деградацией. 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;

// используется вместо FitRect при ActualSize = True
procedure ActualSizeRect(Pdf: TPdf; out X, Y, W, H: Integer);
begin
  // 72 пункта на дюйм со стороны PDF, пиксели устройства на дюйм у принтера
  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 действия на настоящей границе печати

Документные печатные скрипты исполняются, когда их исполняет хозяин. Словарь дополнительных действий каталога (ISO 32000-1 §12.6.3, Table 197) определяет WP — JavaScript-действие перед печатью — и DP, исполняемое после. PDFium Component выставляет оба через ExecuteDocumentLifecycleAction(dlaWillPrint, ErrorMessage) и dlaDidPrint и держит их вне вызовов рендера: миниатюры, снимки и конвертации тоже рендерят, и ни одна из них не должна выглядеть для документного скрипта как пользовательская печать. Порядок — ваша забота. Will-print скрипт, штампующий дату или заполняющий поле «напечатано кем», меняет значения форм, поэтому гоняйте его до рендера первой страницы, а на флэттен-маршруте — до сохранения копии; did-print стреляйте только после того, как EndDoc закоммитил задание. Вызов возвращает False с сообщением, когда форм-окружение отсутствует или его отказывает LifecycleActionPolicy; для недоверенных файлов lapDisabled выключает документные действия. Более широкая картина скриптинга — в запуске AcroForm JavaScript с событиями хоста

На Windows задание, обязанное нести значения форм, живёт на битмапной ветке: RenderPage в TBitmap при обычных разрешениях, RenderPageBanded при 600 dpi и выше. Перегруз через контекст устройства у PDFium Component придержите для флэттен-копий и документов без полей, где его векторный вывод — лучший результат