Улучшенный класс PrintPreviewDialog с выводом в формат PDF

Данная статья описывает реализацию улучшенного класса PrintPreviewDialog, который обеспечивает вывод в формате PDF в дополнение к стандартным возможностям печати и предварительного просмотра.

Исходные данные

Класс PrintPreviewDialog удобен и прост в использовании. Необходимо всего лишь создать экземпляр диалогового класса, присвоить объект PrintDocument свойству Document и вызвать метод ShowDialog.

Тем не менее, у класса PrintPreviewDialog есть и некоторые недостатки, в том числе следующие.

  • Для отображения предварительного просмотра необходимо преобразовать весь документ. В случае большого объема документов это может раздражать.
  • Нет опций выбора принтера, настройки макета страницы или выбора конкретных страниц для печати.
  • Диалоговое окно выглядит устаревшим. Оно не менялось с момента выпуска версии .NET 1.0, да и в то время не было самым передовым.
  • Диалог практически не имеет пользовательских настроек.
  • Нет варианта экспорта документа в другие форматы, в частности PDF.
  • Образы страниц кэшируются в элементе управления, что приводит к ограничениям размера документов, доступных для предварительного просмотра.

Представленный здесь класс CoolPrintPreviewDialog исправляет данные недостатки. Его так же легко использовать, как стандартный класс PrintPreviewDialog, но при этом доступны следующие улучшения.

  • Страницы доступны к предварительному просмотру сразу после преобразования. Первая страница отображается практически мгновенно, а последующие страницы становятся доступны, пока в браузере отображаются первые.
  • При нажатии кнопки "Print" (печать) отображается диалоговое окно, в котором можно выбрать принтер и диапазон страниц для печати. С помощью кнопки "Page Layout" (Макет страницы) пользователи могут менять размер, ориентацию и поля страницы.
  • Диалоговое окно использует элемент управления ToolStrip вместо старой панели инструментов.
  • У вас есть источник, а также возможность индивидуальных настроек всех параметров – от внешнего вида до поведения.
  • Элемент управления создает список изображений, которые могут быть экспортированы в другие форматы, в том числе PDF.

Использование кода

Использовать класс CoolPrintPreviewDialog так же просто, как и PrintPreviewDialog. Необходимо реализовать элемент управления, задать для свойства Document значение PrintDocument для предварительного просмотра, а затем вызвать метод Show для диалога.

Если существующий код использует класс PrintPreviewDialog, переход к CoolPrintPreviewDialog потребует изменения лишь одной строки кода. Например:

// using a PrintPreviewDialog
using (var dlg = new PrintPreviewDialog())
{
dlg.Document = this.printDocument1;
dlg.ShowDialog(this);
}
 
// using a CoolPrintPreviewDialog
using (var dlg = new CoolPrintPreview.CoolPrintPreviewDialog())
{
dlg.Document = this.printDocument1;
dlg.ShowDialog(this);
}

Создание изображений для просмотра

В основе класса CoolPrintPreviewDialog лежит элемент управления CoolPreviewControl, который выполняет создание и отображение предварительного просмотра страницы.

У объектаPrintDocument есть свойство PrintController, которое описывает объект, ответственный за создание объектов Graphics, в которых выполняется преобразование документа. Контроллер печати по умолчанию создает объекты Graphics для принтера по умолчанию и в данном случае он не рассматривается. Также .NET приводит определение класса PreviewPrintController для создания метафайлов. Эти файлы доступны тому, что вызвал метод, и отображаются в области предварительного просмотра.

Элемент управленияCoolPreviewControl работает следующим образом: выполняется замена изначального контроллера печати документа на PreviewPrintController, затем для документа вызывается метод Print для получения изображений страниц в процессе преобразования документа. Изображения соответствуют страницам в документе. Выполнятся масштабирование и последующее отображение страниц в элементе управления, аналогично любому объекту типа Image (изображение).

Для создания предварительного просмотра страниц используется следующий код (здесь приводится упрощенная версия, полную версию см. в источнике):

// list of page images
List<Image> _imgList = new List<Image>();
 
// generate page images
public void GeneratePreview(PrintDocument doc)
{
  // save original print controller
PrintController savePC = doc.PrintController;
 
  // replace it with a preview print controller
doc.PrintController = new PreviewPrintController();
 
  // hook up event handlers
doc.PrintPage += _doc_PrintPage;
doc.EndPrint += _doc_EndPrint;
 
  // render the document
_imgList.Clear();
doc.Print();
 
  // disconnect event handlers
doc.PrintPage -= _doc_PrintPage;
doc.EndPrint -= _doc_EndPrint;
 
  // restore original print controller
doc.PrintController = savePC;
}

С помощью данного кода выполняется установка контроллера и подключение обработчиков событий, затем вызов метода Print для создания страниц и очистка после выполнения задачи.

При вызове метода Print документ начинает инициировать события. Обработчики событий PrintPage и EndPrint захватывают страницы сразу после построения и добавляют их во внутренний список изображений.

Обработчики изображения также вызывают метод Application.DoEvents, чтобы диалоговое окно реагировало на действия пользователя в процессе построения документа. Это позволяет пользователям переключать страницы, настраивать масштаб просмотра или отменить процесс создания документа. Без вызова данного метода диалоговое окно перестало бы реагировать на действия пользователя до окончания преобразования документа целиком.

Эти действия выполняются следующим кодом.

void _doc_PrintPage(object sender, PrintPageEventArgs e)
{
SyncPageImages();
  if (_cancel)
  {
e.Cancel = true;
  }
}
void _doc_EndPrint(object sender, PrintEventArgs e)
{
SyncPageImages();
}
void SyncPageImages()
{
  // get page previews from print controller
  var pv = (PreviewPrintController)_doc.PrintController;
  var pageInfo = pv.GetPreviewPageInfo();
 
  // add whatever images are missing from our internal list
  for (int i = _img.Count; i < pageInfo.Length; i++)
  {
    // add to internal list
_img.Add(pageInfo[i].Image);
 
    // fire event to indicate we have more pages
OnPageCountChanged(EventArgs.Empty);
   
    // if the page being previewed changed, refresh to show it
    if (StartPage < 0) StartPage = 0;
    if (i == StartPage || i == StartPage + 1)
    {
Refresh();
    }
   
    // keep application responsive
Application.DoEvents();
  }
}

Это ядро кода просмотра. Остальная часть кода занимается такими рутинными задачами, как масштабирование изображений для предварительного просмотра, обновление линеек прокрутки, управление кнопками навигации, мышкой, клавиатурой и т.д. Для получения подробных сведений о реализации обратитесь к источнику кода.

Обновление макета страницы

Диалоговое окно предварительного просмотра позволяет пользователю обновить макет страницы. Благодаря классу PageSetupDialog в .NET это легко реализуемо. Ниже приводится код, который исполняется при нажатии пользователем кнопки "Page Layout" (макет страницы).

void _btnPageSetup_Click(object sender, EventArgs e)
{
  using (var dlg = new PageSetupDialog())
  {
dlg.Document = Document;
    if (dlg.ShowDialog(this) == DialogResult.OK)
    {
      // user changed the page layout, refresh preview images
_preview.RefreshPreview();
    }
  }
}

Данный код используется для отображения диалогового окна PageSetupDialog, в котором пользователь может изменить размер, ориентацию и поля страницы. Внесенные пользователем изменения отражаются в свойстве документа DefaultPageSettings.

При нажатии пользователем кнопки "ОК" мы предполагаем, что макет страницы был изменен и вызываем метод RefreshPreview для элемента управления предварительного просмотра. Данный метод выполняет повторное создание всех изображений для предварительного просмотра с новыми настройками, а пользователь получает возможность увидеть изменения в полях, ориентации страницы и т.п.

Печать документа

При нажатии пользователем кнопки "Print" (печать) отображается диалоговое окноPrintDialog, где пользователь может выбрать принтер, диапазон страниц или отменить печать.

К сожалению, выбор диапазона страницы не учитывается при простом вызове методаPrint напрямую для документа. Чтобы обойти это упущение, диалоговое окно вызывает метод Print для улучшенного элемента управления предварительного просмотра. Такая реализация использует изображения страниц, которые уже хранятся в элементе управления, и учитывает диапазоны страниц, определенные в свойствах документа PrinterSettings.

Ниже приводится код, который вызывается при нажатии пользователем кнопки "Print" (печать).

void _btnPrint_Click(object sender, EventArgs e)
{
  using (var dlg = new PrintDialog())
  {
    // configure dialog
dlg.AllowSomePages = true;
    dlg.AllowSelection = true;
dlg.Document = Document;
 
    // show allowed page range
    var ps = dlg.PrinterSettings;
ps.MinimumPage = ps.FromPage = 1;
ps.MaximumPage = ps.ToPage = _preview.PageCount;
 
    // show dialog
    if (dlg.ShowDialog(this) == DialogResult.OK)
    {
      // print selected page range
_preview.Print();
    }
  }
}

Сначала метод Print определяет в элементе управления предварительного просмотра диапазон страниц, которые необходимо подготовить. Это может быть документ полностью, определенный диапазон страниц или текущий выбор (страницы в предварительном просмотре). После определения диапазона страниц код создает класс поддержки DocumentPrinter для запуска собственно печати.

public void Print()
{
  // select pages to print
  var ps = _doc.PrinterSettings;
  int first = ps.MinimumPage - 1;
  int last = ps.MaximumPage - 1;
  switch (ps.PrintRange)
  {
    case PrintRange.CurrentPage:
first = last = StartPage;
break;
    case PrintRange.Selection:
first = last = StartPage;
      if (ZoomMode == ZoomMode.TwoPages)
last = Math.Min(first + 1, PageCount - 1);
break;
    case PrintRange.SomePages:
first = ps.FromPage - 1;
last = ps.ToPage - 1;
break;
    }
 
    // print using helper class
    var dp = new DocumentPrinter(this, first, last);
dp.Print();
  }
}

Класс DocumentPrinter достаточно прост. Он наследует свойства у класса PrintDocument и перекрывает метод OnPrintPage для печати только тех страниц, которые выбраны пользователем.

internal class DocumentPrinter : PrintDocument
{
  int _first, _last, _index;
List<Image> _imgList;
 
  public DocumentPrinter(CoolPrintPreviewControl preview, int first, int last)
  {
    // save page range and image list
_first = first;
    _last = last;
_imgList = preview.PageImages;
   
    // copy page and printer settings from original document
DefaultPageSettings = preview.Document.DefaultPageSettings;
PrinterSettings = preview.Document.PrinterSettings;
  }
 
protected override void OnBeginPrint(PrintEventArgs e)
  {
    // start from the first page
_index = _first;
  }
protected override void OnPrintPage(PrintPageEventArgs e)
  {
    // render the current page and increment the index
e.Graphics.PageUnit = GraphicsUnit.Display;
    e.Graphics.DrawImage(_imgList[_index++], e.PageBounds);
   
    // stop when we reach the last page in the range
e.HasMorePages = _index <= _last;
  }
}

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

Экспорт в PDF

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

После того, как выполнено преобразование PrintDocument в серию изображений, мы можем использовать компонент C1PdfDocument для преобразования изображений в документ PDF. С помощью данного подхода, в любое приложение, которое использует класс PrintDocument для обеспечения печати и предварительного просмотра, можно добавить функцию экспорта в PDF с минимальными усилиями.

Первым действием при реализации экспорта в PDF будет реализация обработчика событий, который вызывается при нажатии пользователем кнопки экспорта в PDF в PrintPreviewDialog.

void _btnPdf_Click(object sender, EventArgs e)
{
  using (var dlg = new SaveFileDialog())
  {
dlg.Filter = "Portable Document File (*.pdf)|*.pdf";
dlg.DefaultExt = ".pdf";
    if (dlg.ShowDialog() == DialogResult.OK)
    {
      pdfGenerator = new PrintDocumentPdfExporter(_pdf);
      if (pdfGenerator.RenderDocument(Document, true))
      {
_pdf.Save(dlg.FileName);
      }
    }
  }
}

Код использует диалоговое окно SaveFileDialog, чтобы запросить у пользователя имя файла PDF для последующего сохранения, а такжеPrintDocumentPdfExporter для создания файла PDF из изображений для предварительного просмотра.

Класс PrintDocumentPdfExporter может быть использован независимо от класса C1PrintPreviewDialog. Например, для создании кнопки для экспорта в PDF в основном приложении, которая вообще не вызывает диалоговое окно предварительного просмотра.

Ниже показана реализация класса PrintDocumentPdfExporter.

class PrintDocumentPdfExporter
{
  // ** fields
PrintDocument _doc;
C1PdfDocument _pdf;
PreviewPrintController _previewController;
  int _pageCount;
  bool _cancel;
 
  // ** ctor
  public PrintDocumentPdfExporter(C1PdfDocument pdf)
  {
    // save reference to pdf component
    _pdf = pdf;
  }

Конструктор просто сохраняет ссылку на C1PdfDocument, который будет использован для создания PDF-файла.

Основной общий метод в классе называется RenderDocument. Сначала метод присваивает документу класс PreviewPrintController, который выполняет преобразование страниц в изображения метафайлов. При этом может использоваться как простейший контроллер PreviewPrintController, так и оболочка PrintControllerWithStatusDialog, в зависимости от значения параметра showProgressDialog. Оба этих класса входят в платформу .NET.

После установки контроллера код выполняет соединение обработчиков событий для событий PrintPage иEndPrint. Эти обработчики событий отвечают за преобразование изображений страниц в документ PDF. После установки обработчиков событий код вызывает методPrint для преобразования документа.

Метод возвращает логическое значение, которое обозначает, было ли закончено создание документа (и его следует сохранить) или пользователь отменил процесс, нажав кнопку "Cancel" (отмена) в дополнительном диалоговом окне, отображающем ход работы.

// ** object model
  public bool RenderDocument(PrintDocument doc, bool showProgressDialog)
  {
    // save reference to document
    _doc = doc;
 
    // initialize pdf document
_pdf.Clear();
_pdf.Landscape = false;
 
    // prepare to render
    var savePC = _doc.PrintController;
_previewController = new PreviewPrintController();
_doc.PrintController = showProgressDialog
      ? new PrintControllerWithStatusDialog(_previewController)
      : (PrintController)_previewController;
_pageCount = 0;
_cancel = false;
 
    // render
    try
    {
      _doc.PrintPage += _doc_PrintPage;
_doc.EndPrint += _doc_EndPrint;
_doc.Print();
    }
finally
    {
_doc.PrintPage -= _doc_PrintPage;
_doc.EndPrint -= _doc_EndPrint;
_doc.PrintController = savePC;
    }
 
    // done
    return !_cancel;
  }

Ниже перечислены обработчики событий для документа. По мере обработки страниц обработчики вызывают метод DrawPage для преобразования изображений страниц (метафайлов) в документ PDF. Это выполняется с помощью метода C1PdfDocument.DrawImage.

В методе C1PdfDocument.DrawImage перечислены команды GDI в метафайле. Затем метод выполняет преобразование каждой команды в соответствующую векторную команду PDF. При этом не выполняется преобразование метафайла в растровое изображение, то есть его содержимое остается компактным и доступным для масштабирования и поиска.

// ** PrintDocument event handlers
  void _doc_PrintPage(object sender, PrintPageEventArgs e)
  {
    var pages = _previewController.GetPreviewPageInfo();
    while (_pageCount < pages.Length - 1)
    {
DrawPage(pages, _pageCount++);
    }
    if (e.Cancel)
    {
_cancel = true;
    }
  }
  void _doc_EndPrint(object sender, PrintEventArgs e)
  {
    var pages = _previewController.GetPreviewPageInfo();
DrawPage(pages, _pageCount);
    if (e.Cancel)
    {
_cancel = true;
    }
  }
  void DrawPage(PreviewPageInfo[] pages, int index)
  {
    // skip to next page
    if (index > 0)
    {
_pdf.NewPage();
    }
 
    // get preview page info
    var pi = pages[index];
 
    // adjust page size
    var ps = pi.PhysicalSize;
    _pdf.PageSize = new SizeF(ps.Width / 100f * 72, ps.Height / 100f * 72);
 
    // draw image
    var img = pi.Image;
_pdf.DrawImage(img, _pdf.PageRectangle);
  }
}

Предварительный просмотр очень длинных документов

После публикации первой версии проекта я получил много ценных комментариев от других пользователей CodeProject. В одном из них встречалась проблема, с которой я справился некоторое время назад. Если в документе несколько тысяч страниц могут возникать проблемы при кэшировании образов всех этих страниц. ОС Windows налагает ограничение в 10 000 объектов GDI, а каждый образ представляет собой по крайней мере один такой объект. Если вы используете слишком много объектов GDI, приложение может завершиться с ошибкой или вызвать ошибку других программ. Что не очень хорошо...

Самым простым способом решения этой проблемы становится конвертация изображений страниц в потоки. Затем вы можете хранить потоки и создавать изображения по запросу, только когда они необходимы для предварительного просмотра или печати.

Код ниже представляет собой класс PageImageList, который выполняет данное задание. Его можно использовать как обычный классList за одним исключением: при получении или задании изображения оно будет автоматически преобразовано в или из массива байтов. Таким образом хранимые в списке изображения не являются объектами GDI и не приводят к исчерпанию ресурсов системы.

// This version of the PageImageList stores images as byte arrays. It is a little
// more complex and slower than a simple list, but doesn't consume GDI resources.
// This is important when the list contains lots of images (Windows only supports
// 10,000 simultaneous GDI objects!)
class PageImageList
{
  // ** fields
  var _list = new List<byte[]>();
 
  // ** object model
  public void Clear()
  {
_list.Clear();
  }
  public int Count
  {
    get { return _list.Count; }
  }
  public void Add(Image img)
  {
_list.Add(GetBytes(img));
 
    // stored image data, now dispose of original
img.Dispose();
  }
  public Image this[int index]
  {
    get { return GetImage(_list[index]); }
    set { _list[index] = GetBytes(value); }
  }
 
  // implementation
  byte[] GetBytes(Image img)
  {
    // clone image since GetEnhMetaFileBits is destructive
    var clone = img.Clone() as Metafile;
 
    // use interop to get the metafile bits
    var enhMetafileHandle = clone.GetHenhmetafile().ToInt32();
    var bufferSize = GetEnhMetaFileBits(enhMetafileHandle, 0, null);
    var buffer = new byte[bufferSize];
GetEnhMetaFileBits(enhMetafileHandle, bufferSize, buffer);
 
    // done with clone (already destroyed by call to GetEnhMetaFileBits)
clone.Dispose();
 
    // return bits
return buffer;
  }
  Image GetImage(byte[] data)
  {
MemoryStream ms = new MemoryStream(data);
    return Image.FromStream(ms);
  }
 
[System.Runtime.InteropServices.DllImport("gdi32")]
  static extern int GetEnhMetaFileBits(int hemf, int cbBuffer, byte[] lpbBuffer);
}

Следует принять во внимание, что метод Add избавляется от изображения после его сохранения. Обычно так не делается, так как тот, кто вызвал метод, является владельцем изображения и должен сам принимать решения об его удалении. Но именно в этом проекте мы можем заменить реализацию PageImageList обычным классом List, что удобно для тестирования и определения производительности.

Также следует помнить, чтоGetBytes использует интерфейс API GetHenhMetaFileBits. Данный API приводит к появлению неверного файла, поэтому изображение не может быть использовано после вызова данного метода. Чтобы избежать разрушения изначального файла, метод сначала создает его клон, а затем получает биты из клона.

Если вас волнуют проблемы производительности, дополнительные конвертации приводят к снижению производительности примерно на 10% при генерации документа. Я полагаю, что это достаточно небольшая цена за те преимущества, которые вы получаете при обработке документов из нескольких сотен или тысяч страниц.

Если вы озабочены использованием памяти, рассмотрите возможность архивации массивов байтов при хранении. Архивацию легко выполнить методом C1Zip, а метафайлы вообще хорошо поддаются сжатию. Конечно, при этом производительность еще немного снизится.

Заключение

Мы выполнили реализацию улучшенного класса PrintPreviewDialog, который обеспечивает вывод PDF в дополнение к стандартным возможностям печати и предварительного просмотра. Если у вас есть запросы или предложения в отношении улучшения данного документа или приложения, вы можете всегда опубликовать их на сайте.