增强的 PrintPreviewDialog






4.97/5 (104投票s)
比标准对话框更快、

引言
本文介绍了一个增强的 PrintPreviewDialog
类的实现。
背景
PrintPreviewDialog
方便易用。您只需要创建对话框类的实例,将 PrintDocument
对象分配给 Document
属性,然后调用 ShowDialog
方法即可。
然而,PrintPreviewDialog
存在一些不足之处,包括以下几点:
- 必须先渲染整个文档,预览才能出现。这对于长文档来说非常令人沮丧。
- 没有选项可以选择打印机、调整页面布局或选择要打印的特定页面。
- 对话框看起来过时了。它自 .NET 1.0 以来就没有改变过,而且即使在当时,它也不是最前沿的设计。
- 对话框几乎没有或根本无法自定义。
- 没有导出文档到 PDF 等其他格式的选项。
- 页面图像被缓存到控件中,这限制了可以预览的文档的大小。
这里提出的 CoolPrintPreviewDialog
类解决了这些不足之处。它易于使用,与标准的 PrintPreviewDialog
一样,但具有以下增强功能:
- 页面可以在渲染完成后立即预览。第一页几乎会立即显示,而后续页面将在用户浏览第一页时可用。
- “打印”按钮会显示一个对话框,允许用户选择打印机和要打印的页面范围。还有一个“页面布局”按钮,用户可以更改纸张大小、方向和页边距。
- 对话框使用
ToolStrip
控件代替了旧的工具栏。 - 您拥有源代码,可以自定义从外观到行为的一切。
- 该控件创建一个图像列表,可以导出为其他格式,包括 PDF(尽管此处提供的版本实际上并未实现此功能)。
Using the Code
使用 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();
}
}
这是预览代码的核心。其余部分用于处理诸如缩放预览图像、更新滚动条、处理导航按钮、鼠标、键盘等家务管理任务。请参考源代码以获取实现细节。
更新页面布局
预览对话框允许用户更新打印布局。这非常容易实现,这要归功于 .NET 的 PageSetupDialog
类。这是当用户单击“页面布局”按钮时调用的代码:
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
方法。此方法使用新设置重新生成所有预览图像,以便用户可以看到对页边距、页面方向等所做的更改。
打印文档
当用户单击“打印”按钮时,对话框会显示一个 PrintDialog
,以便用户可以选择打印机、页面范围,或者改变主意取消打印。
不幸的是,如果您直接在文档上调用 Print
方法,将不遵守页面范围选择。为了解决这个问题,对话框会调用增强的预览控件上的 Print
方法。该实现使用控件中已存储的页面图像,并遵守文档 PrinterSettings
属性中定义的页面范围。
这是当用户单击“打印”按钮时调用的代码:
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;
}
}
此实现假定所有页面的大小和方向都相同,然后渲染页面图像,这对于大多数文档来说是这样。如果文档包含不同大小或不同方向的页面,则此简单实现将无法正常工作。要解决此问题,我们必须在打印每个页面之前检查当前纸张大小和方向是否与预览图像大小匹配,并在必要时调整打印机设置。这留给读者作为练习。
预览非常长的文档
在我发布此项目的第一个版本后,我收到了一些来自 CodeProject 用户的很棒的反馈。其中一位提到了我以前也遇到过的一个问题。如果文档包含数千页,缓存所有这些图像可能会导致问题。Windows 对 GDI 对象的数量限制为 10,000 个,而每个页面图像至少代表一个。如果您使用过多的 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)
{
Metafile mf = img as Metafile;
var enhMetafileHandle = mf.GetHenhmetafile().ToInt32();
var bufferSize = GetEnhMetaFileBits(enhMetafileHandle, 0, null);
var buffer = new byte[bufferSize];
GetEnhMetaFileBits(enhMetafileHandle, bufferSize, buffer);
// return bits
return buffer;
}
Image GetImage(byte[] data)
{
MemoryStream ms = new MemoryStream(data);
return Image.FromStream(ms);
}
// use interop to get the metafile bits
[System.Runtime.InteropServices.DllImport("gdi32")]
static extern int GetEnhMetaFileBits(int hemf, int cbBuffer, byte[] lpbBuffer);
}
请注意,Add
方法在存储图像后将其处置。我通常不会这样做;调用者拥有图像,并应负责处置它。但在本项目中,这种安排允许我将 PageImageList
实现与常规 List
交换,这对于测试和基准测试很方便。
还请注意,GetBytes
使用 GetHenhmetafile
API。此 API 会使图元文件失效,因此调用此方法后无法使用原始图像。在这种情况下,这不是问题,因为图像在转换后就会被销毁。但是,如果您想在其他应用程序中使用此代码,请记住,在此调用后您不能使用图元文件。如果您需要图像,请使用类似于上面 GetImage
方法的代码重新创建它。
如果您关心性能,额外的转换会在生成文档时导致大约 10% 的性能损失。我认为,如果您的文档有数百或数千页,这是为了获得好处而付出的合理代价。
如果您担心内存使用,请考虑在存储字节数组时对其进行压缩。我在原始实现中这样做了;图元文件通常压缩得很好。当然,这会带来一点额外的性能开销。
使用鼠标滚轮进行缩放
按住 Control 键并滚动滚轮已成为缩放文档的实际标准命令,大多数现代应用程序(包括浏览器和 MS Office)都支持此功能。
为 CoolPrintPreview 控件添加此支持,只需重写 OnMouseWheel
方法并更改 Zoom
属性的值即可,如下所示:
// zoom in/out using control+wheel
protected override void OnMouseWheel(MouseEventArgs e)
{
if ((Control.ModifierKeys & Keys.Control) != 0)
{
var zoom = Zoom *= e.Delta > 0 ? 1.1 : 0.9;
Zoom = Math.Min(5, Math.Max(.1, zoom));
}
base.OnMouseWheel(e);
}
此更改已包含在 CoolPrintPreviewDialog
的 CoolPrintPreview2010.zip 版本中。历史
这是 CoolPrintPreviewDialog
文章的第三个版本。此版本对 PageImageList
类进行了更短、更高效的实现,并且还包含了一个 Visual Basic 实现。
在我发布第一个版本后,有人指向了另一篇关于 PrintPreviewDialog
的文章,但我在撰写此文章时不知何故错过了它。它有一个略微不同的重点,并且非常精美地描述了本地化。希望您能从两者中获得一些好主意。 在此处查看。
感谢您迄今为止的反馈。如果您有其他要求或改进建议,请告诉我。祝您预览愉快!