Технічна стаття

Друк PDF-документів за допомогою PDFium Component у Delphi

Координати PDF вимірюються в пунктах (points), а координати принтера - в одиницях пристрою (device units), і вони не мають нічого спільного, поки ви не виконаєте їхнє явне перетворення. Саме ця невідповідність є основною причиною проблем із друком у застосунках Delphi: код відправляє правильний файл, але сторінка виходить обрізаною, розтягнутою або взагалі порожньою. PDFium Component чисто обробляє частину рендерингу, тоді як робота з принтером покладається на стандартний VCL. Вони легко поєднуються за допомогою невеликої кількості коду, якщо ви розумієте, чого очікує кожна зі сторін

Як працює конвеєр рендерингу та друку

PDFium Component не взаємодіє з принтерами безпосередньо. Патерн такий: ви рендерите сторінку в TBitmap із потрібною роздільною здатністю, а потім переносите цей бітмап на canvas принтера за допомогою StretchDIBits. TPdf.RenderPage повертає бітмап, яким володіє сторона виклику, тому ви повністю контролюєте розміри в пікселях. Якщо передати [rePrinting] у наборі параметрів, PDFium перемикає свій шлях рендерингу на такий, що пропускає суто екранні ефекти (наприклад, LCD subpixel hinting), та коректно обробляє MediaBox сторінки для друку. Якщо ж опустити rePrinting, ви відправите на принтер екранний рендер, який виглядає нормально на моніторі, але зазвичай дає менш чіткий результат на принтерах із високим DPI. Це відбувається через те, що рішення для згладжування (hinting), розраховані на 96 DPI, не підходять для друку з роздільною здатністю 300 або 600 DPI

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

Мінімальний цикл друку

Найпростіший робочий варіант завантажує файл, відкриває завдання на друк, перебирає сторінки та закриває завдання. Єдина тонка деталь полягає в тому, що Printer.NewPage не можна викликати перед першою сторінкою - саме для цього і потрібен прапорець FirstPage. Передача через StretchDIBits використовує GetDIBSizes та GetDIB для вилучення незалежних від пристрою бітів (device-independent bits) з дескриптора бітмапа, після чого малює їх на canvas принтера на повний розмір сторінки:

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 без змін

Перевірте цю поведінку на вашому конкретному цільовому принтері перед релізом (shipping). Поведінка драйверів щодо повороту не є однаковою у різних виробників, і деякі драйвери застосовують власну корекцію обертання на рівні GDI. Якщо ви бачите подвійне обертання - видаліть заміну; якщо ж корекція взагалі відсутня - додайте її. Документ зі змішаною орієнтацією, у якому чергуються книжкові та альбомні сторінки, є найшвидшим способом виявити будь-який із цих варіантів помилок під час тестування

Управління пам'яттю під час великих завдань на друк

Кожен виклик RenderPage виділяє пам'ять для нового об'єкта TBitmap, яким володіє викликаюча сторона і який вона повинна звільнити. У циклі вище блок try/finally Bitmap.Free коректно обробляє це для кожної окремої сторінки. Не накопичуйте бітмапи між сторінками: рендеринг 200-сторінкового документа при 300 DPI забере гігабайти пам'яті ще до того, як перша сторінка потрапить до диспетчера друку. Звільняйте кожен бітмап перед переходом до наступної сторінки

Пара функцій AllocMem / FreeMem всередині блоку передачі даних слідує тому ж правилу. GetDIBSizes вказує, скільки пам'яті потрібно для заголовка DIB і даних пікселів; ви виділяєте пам'ять, заповнюєте, малюєте і звільняєте її в межах циклу однієї сторінки. Допущення витоку пам'яті у будь-якому з цих блоків призведе до того, що завдання на друк вичерпає купу процесу (process heap) на документах обсягом понад кілька десятків сторінок

Якщо вам потрібно виконувати завдання на друк у фоновому потоці, тримайте TPdf та всі виклики VCL-принтера в одному і тому ж потоці. Сам TPdf не є потокобезпечним (thread-safe) між екземплярами, які використовують спільний глобальний стан DLL PDFium; найбезпечніша модель - це один екземпляр TPdf на потік, кожен із яких завантажує власну копію файлу

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