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

Печать документов PDF с помощью компонента PDFium в Delphi

Координаты PDF измеряются в пунктах, координаты принтера — в единицах устройства, и они не имеют друг с другом ничего общего, пока вы не преобразуете их намеренно. Это несоответствие является корнем большинства проблем с плохим выводом на печать в приложениях Delphi: код отправляет правильный файл, но страница получается обрезанной, растянутой или пустой. Компонент PDFium чисто справляется со стороной рендеринга; настройка принтера — это стандартный VCL. Эти две части стыкуются с помощью небольшого объема кода, как только вы поймете, чего ожидает каждая сторона

Как работает конвейер рендеринга и печати

Компонент PDFium не взаимодействует с принтерами напрямую. Шаблон таков: отрендерить страницу в TBitmap с нужным вам разрешением, а затем перенести это растровое изображение на холст принтера с помощью StretchDIBits. TPdf.RenderPage возвращает принадлежащее вызывающей стороне растровое изображение, поэтому вы контролируете размеры в пикселях. Передайте [rePrinting] в набор параметров, и PDFium переключит свой путь рендеринга на тот, который опускает эффекты, предназначенные только для экрана, такие как субпиксельный хинтинг LCD, и правильно обрабатывает MediaBox страницы для вывода на печать. Если не указывать rePrinting, то на принтер будет отправлен экранный рендер, который выглядит нормально на мониторе, но, как правило, дает более мягкий вывод на принтерах с высоким DPI, поскольку решения о хинтинге, принятые для экранов с разрешением 96 DPI, не подходят для печати с разрешением 300 или 600 DPI

TPdf.Active — это единственный шлюз, который нужно проверять перед тем, как прикасаться к любому свойству страницы. Компонент молча проглатывает ошибки загрузки: установка Active := True для поврежденного или защищенного паролем файла не вызывает исключения; он просто оставляет Active равным False. Всегда проверяйте его после присвоения. Чтение PageCount или PageWidth неактивного документа возвращает ноль, что приводит к молчаливым холостым операциям (no-ops), которые очень трудно диагностировать после того, как они достигают диспетчера очереди печати

Минимальный цикл печати

Самый простой рабочий случай загружает файл, открывает задание на печать, перебирает страницы и закрывается. Единственная сложная деталь заключается в том, что Printer.NewPage не должен вызываться перед первой страницей, отсюда и флаг FirstPage. Перенос StretchDIBits проходит через GetDIBSizes и GetDIB, чтобы вытащить независимые от устройства биты из дескриптора растрового изображения, а затем рисует их на холсте принтера в полный размер страницы:

procedure PrintPdfFile(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Bitmap: TBitmap;
  InfoHeaderSize, ImageSize: DWORD;
  InfoHeader: PBitmapInfo;
  Image: Pointer;
  FirstPage: Boolean;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    if not Pdf.Active then
      Exit;  // load failed silently; bail out

    Printer.Title := Pdf.Title;
    Printer.BeginDoc;
    try
      FirstPage := True;
      for I := 1 to Pdf.PageCount do
      begin
        if FirstPage then
          FirstPage := False
        else
          Printer.NewPage;

        Pdf.PageNumber := I;

        // Render at printer resolution; rePrinting adjusts the render path
        Bitmap := Pdf.RenderPage(
          0, 0,
          Printer.PageWidth,
          Printer.PageHeight,
          ro0,
          [rePrinting]
        );
        try
          GetDIBSizes(Bitmap.Handle, InfoHeaderSize, ImageSize);
          InfoHeader := AllocMem(InfoHeaderSize);
          try
            Image := AllocMem(ImageSize);
            try
              GetDIB(Bitmap.Handle, 0, InfoHeader^, Image^);
              StretchDIBits(
                Printer.Canvas.Handle,
                0, 0, Printer.PageWidth, Printer.PageHeight,
                0, 0, Bitmap.Width, Bitmap.Height,
                Image, InfoHeader^, DIB_RGB_COLORS, SRCCOPY
              );
            finally
              FreeMem(Image);
            end;
          finally
            FreeMem(InfoHeader);
          end;
        finally
          Bitmap.Free;
        end;
      end;
    finally
      Printer.EndDoc;
    end;
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Передача Printer.PageWidth и Printer.PageHeight в качестве размеров растрового изображения означает, что вы выполняете рендеринг в родном размере пикселей принтера, который уже учитывает DPI устройства. Затем вызов StretchDIBits сопоставляет эти пиксели 1:1 со страницей. Это дает вам наилучшую достижимую точность без явной арифметики DPI, но работает только в том случае, если страница PDF и физическая бумага имеют одинаковый размер. Когда они различаются, требуется явное масштабирование

Масштабирование, когда размеры страницы и бумаги различаются

Страница PDF в портретной ориентации A4 не подходит автоматически для принтера US Letter, а альбомная страница, поданная на принтер с портретной ориентацией, будет обрезана. Стандартный подход заключается в вычислении единого коэффициента масштабирования из отношения пикселей принтера к пунктам PDF с последующим применением его к обоим измерениям, чтобы сохранилось соотношение сторон. Pdf.PageWidth и Pdf.PageHeight раскрывают текущие размеры страницы в пунктах, где один пункт равен 1/72 дюйма. Умножение на целевой DPI и деление на 72 преобразует в пиксели при этом разрешении. Возьмите Min (минимум) из отношений X и Y, чтобы получить наибольший масштаб, который все еще вписывается в область печати:

// Fit PDF page to printable area, preserving aspect ratio
var
  ScaleX, ScaleY, Scale: Double;
  DestWidth, DestHeight: Integer;
  Dpi: Integer;
begin
  Dpi := 300;  // target render resolution
  Pdf.PageNumber := PageIndex;

  ScaleX := Printer.PageWidth  / (Pdf.PageWidth  * Dpi / 72);
  ScaleY := Printer.PageHeight / (Pdf.PageHeight * Dpi / 72);
  Scale  := Min(ScaleX, ScaleY);

  // Clamp to 1.0 for shrink-to-fit only (no enlargement)
  if Scale > 1.0 then Scale := 1.0;

  DestWidth  := Round(Pdf.PageWidth  * Dpi / 72 * Scale);
  DestHeight := Round(Pdf.PageHeight * Dpi / 72 * Scale);

  Bitmap := Pdf.RenderPage(0, 0, DestWidth, DestHeight, ro0,
    [rePrinting, reAnnotations]);
  // ... transfer with StretchDIBits as above
end;

Рендеринг с Dpi = 300 подходит для большинства офисных принтеров. При 600 DPI растровое изображение для одной страницы формата A4 достигает примерно 34 мегапикселей, что составляет около 100 МБ для 32-битного растрового изображения; выигрыш в качестве для обычных текстовых документов минимален, а затраты памяти на страницу значительны. Оставьте 600 DPI для типографий или насыщенных векторами технических чертежей, где это действительно имеет значение

Флаг reAnnotations во втором блоке кода не зависит от rePrinting. Включайте его, когда пользователь ожидает появления на бумаге штампов, выделений и полей для комментариев. Опускайте его для вывода только содержимого. Оба флага можно свободно комбинировать

Вращение страницы

PDFium сохраняет поворот страницы в PDF как запись /Rotate, доступную через Pdf.PageRotation, которая возвращает значение TRotation (ro0, ro90, ro180, ro270). Система координат принтера инвертирует повороты на 90 и 270 градусов относительно экрана. Если вы передадите исходное значение PageRotation непосредственно в RenderPage без какой-либо корректировки, альбомные страницы, встроенные в портретный документ, будут напечатаны вверх ногами на большинстве драйверов принтеров Windows. Исправление представляет собой простую замену перед вызовом рендеринга: сопоставьте ro90 с ro270, а ro270 обратно с ro90, оставив ro0 и ro180 без изменений

Проверьте это поведение на вашем конкретном целевом принтере перед поставкой. Поведение драйверов при вращении не является единообразным у разных поставщиков, и некоторые драйверы применяют собственную коррекцию вращения на уровне GDI. Если вы видите двойное вращение, уберите замену; если вы вообще не видите коррекции, добавьте ее. Документ смешанной ориентации с чередующимися книжными и альбомными страницами — самый быстрый способ отловить любой из режимов сбоя во время тестирования

Управление памятью в течение длительного задания на печать

Каждый вызов RenderPage выделяет новый TBitmap, которым владеет вызывающая сторона, и его необходимо освободить. В цикле выше блок try/finally Bitmap.Free корректно обрабатывает это для одной страницы за раз. Не накапливайте растровые изображения от страницы к странице: рендеринг документа на 200 страниц с разрешением 300 DPI потребует гигабайты памяти до того, как первая страница достигнет диспетчера очереди печати. Освобождайте каждое растровое изображение перед переходом к следующей странице

Пара AllocMem / FreeMem внутри блока передачи следует тому же правилу. GetDIBSizes сообщает вам, сколько памяти нужно для заголовка DIB и данных о пикселях; вы выделяете, заполняете, рисуете и освобождаете все в пределах области действия одной страницы. Утечка любого из этих блоков приведет к тому, что задание печати исчерпает кучу процесса на документах длиной более нескольких десятков страниц

Если вам нужно выполнять задания на печать в фоновом потоке, держите TPdf и все вызовы принтера VCL в одном потоке. Сам TPdf не является потокобезопасным при использовании нескольких экземпляров, разделяющих глобальное состояние DLL PDFium; самая безопасная модель — один TPdf на поток, каждый из которых загружает собственную копию файла

API рендеринга и документов, показанный здесь, является частью компонента PDFium для Delphi и C++Builder