Silverlight 打印基础知识
Silverlight 4 在 Silverlight 功能列表中添加了打印,我想通过向您介绍令我欣慰的小程序来探讨这一点。
该程序称为 PrintEllipse,名称就是它要执行的所有操作。 MainPage 的 XAML 文件包含一个按钮,图 1 中完整地显示了 MainPage 代码隐藏文件。
图 1 PrintEllipse 的 MainPage 代码
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Printing;
using System.Windows.Shapes;
namespace PrintEllipse
{
publicpartialclass MainPage : UserControl
{
public MainPage()
{
InitializeComponent();
}
void OnButtonClick(object sender, RoutedEventArgs args)
{
PrintDocument printDoc =new PrintDocument();
printDoc.PrintPage += OnPrintPage;
printDoc.Print("Print Ellipse");
}
void OnPrintPage(object sender, PrintPageEventArgs args)
{
Ellipse ellipse =new Ellipse
{
Fill =new SolidColorBrush(Color.FromArgb(255, 255, 192, 192)),
Stroke =new SolidColorBrush(Color.FromArgb(255, 192, 192, 255)),
StrokeThickness =24// 1/4 inch
};
args.PageVisual = ellipse;
}
}
}
请注意 System.Windows.Printing 的 using 指令。 在单击此按钮时,该程序将创建一个类型为 PrintDocument 的对象,并为 PrintPage 事件分配一个处理程序。 当程序调用 Print 方法时,将显示标准打印对话框。 用户可借此机会设置要使用的打印机,并设置各种打印属性,例如纵向或横向模式。
当用户单击打印对话框中的“打印”时,该程序将接收到对 PrintPage 事件处理程序的调用。 此特殊程序会通过创建 Ellipse 元素并将该元素设置为事件参数的 PageVisual 属性来进行响应。 (我故意选择淡彩色以便程序不会使用太多油墨。)很快,将从打印机中出来一页,且该页中填充了一个非常大的椭圆。
您可以从网站 bit.ly/dU9B7k 运行此程序并亲自检验。 当然,本文中的所有源代码也是可下载的。
如果您的打印机与大多数打印机一样,则内部硬件将禁止打印机打印到纸张的每个边缘。 打印机通常具有固有的内置边距,不会在边距内打印任何内容;打印内容限制在小于页面全部大小的“可打印区域”上。
关于此程序,您将注意到的是:椭圆整体显示在页面的可打印区域中,很显然,程序可以轻松达到此目的。 页面可打印区域的行为方式与屏幕上的容器元素非常类似:它仅在元素大小超出此区域时才对子项进行剪辑。 一些更复杂的图形环境(例如 Windows Presentation Foundation (WPF))未必有如此好的表现(当然,与 Silverlight 相比,WPF 可提供更多打印控制和灵活性)。
PrintDocument 和事件
除了 PrintPage 事件,PrintDocument 还定义了 BeginPrint 和 EndPrint 事件,但这些事件并非与 PrintPage 一样重要。 BeginPrint 事件表明打印作业的开始。 当用户通过按“打印”按钮退出标准打印对话框并给程序机会执行初始化时,将触发该事件。 调用 BeginPrint 处理程序之后,将对 PrintPage 处理程序进行首次调用。
要在特殊打印作业中打印多页的程序将这样操作。 在对 PrintPage 处理程序的每次调用中,PrintPageEventArgs 的 HasMorePages 属性初始将设置为 false。 当处理程序完成一页后,它只需将该属性设置为 true 即可表明至少必须再打印一页。 然后再次调用 PrintPage。 PrintDocument 对象维护 PrintedPageCount 属性,该属性在每次对 PrintPage 处理程序执行调用后递增。
如果 PrintPage 处理程序退出时 HasMorePages 设置为其默认值 false,打印作业将结束并触发 EndPrint 事件,这样,程序将有机会执行清理任务。 当打印过程中出现错误时也会触发 EndPrint 事件;EndPrintEventArgs 的 Error 属性的类型为 Exception。
打印机坐标
图 1 中显示的代码将 Ellipse 的 StrokeThickness 设置为 24,如果您度量打印结果,您将发现这是四分之一英寸宽。 如您所知,Silverlight 程序通常以像素为单位从整体上调整图形对象和控件的大小。 但是,涉及打印机时,坐标和大小都采用与设备无关的单位,即 1/96 英寸。 不论打印机的实际分辨率如何,在 Silverlight 程序中,打印机始终显示为 96 DPI 设备。
您可能知道,在整个 WPF 中都使用这种 96 个单位为一英寸的坐标系,其中,单位有时称为“与设备无关的像素”。此 96 DPI 值不是随意选择的:默认情况下,Windows 假定您的视频显示器的一英寸具有 96 个点,因此,在很多情况下,WPF 程序实际上以像素为单位进行绘制。 CSS 规范假定视频显示器的分辨率为 96 DPI,该值用于在像素、英寸和毫米之间进行转换。 值 96 还是一个便于转换字体大小的数字,字体大小通常用磅(即 1/72 英寸)来指定。 一磅是一个与设备无关像素的四分之三。
PrintPageEventArgs 具有两个有用的只读属性,这两个属性也以 1/96 英寸为单位报告大小:类型为 Size 的 PrintableArea 提供页面的可打印区域的尺寸,类型为 Thickness 的 PageMargins 是位于左侧、顶部、右侧和底部的不可打印边缘的宽度。 以正确的方式将这两个属性加到一起,您就会得到纸张的完整大小。
我的打印机装载的是标准 8.5 x 11 英寸的纸张并设置为纵向模式,报告 PrintableArea 为 791 x 993。 PageMargins 属性的四个值为 12(左侧)、6(顶部)、12(右侧)和 56(底部)。 如果将水平方向的值 791、12 和 12 相加,将得到 815。 垂直方向的值为 994、6 和 56,加起来是 1,055。 我不确定为什么这些值与将页面大小(以英寸为单位)与 96 相乘所得的值 816 和 1,056 之间存在一个单位的差异。
当打印机设置为横向模式时,PrintableArea 和 PageMargins 报告的水平尺寸和垂直尺寸值将交换。 实际上,查看 PrintableArea 属性是 Silverlight 程序确定打印机是纵向模式还是横向模式的唯一方式。 该程序打印的任何内容将根据此模式自动对齐和旋转。
通常,当您打印现实生活中的内容时,定义的边距将比不可打印边距稍大些。 在 Silverlight 中如何做到这一点呢? 首先,这与设置要打印元素的 Margin 属性一样容易。 此 Margin 是这样计算的:从所需总边距(以 1/96 英寸为单位)中减去 PrintPageEventArgs 中提供的 PageMargins 属性的值。 该方法的效果不是很好,但正确的解决方案几乎一样简单。 PrintEllipseWithMargins 程序(可以在 bit.ly/fCBs3X 上运行)与第一个程序相同,只不过对 Ellipse 设置了 Margin 属性,然后将 Ellipse 设置为将填充可打印区域的 Border 的子项。 或者,您也可以对 Border 设置 Padding 属性。 图 2 显示新的 OnPrintPage 方法。
图 2 用于计算边距的 OnPrintPage 方法
{
Thickness margin =new Thickness
{
Left = Math.Max(0, 96- args.PageMargins.Left),
Top = Math.Max(0, 96- args.PageMargins.Top),
Right = Math.Max(0, 96- args.PageMargins.Right),
Bottom = Math.Max(0, 96- args.PageMargins.Bottom)
};
Ellipse ellipse =new Ellipse
{
Fill =new SolidColorBrush(Color.FromArgb(255, 255, 192, 192)),
Stroke =new SolidColorBrush(Color.FromArgb(255, 192, 192, 255)),
StrokeThickness =24, // 1/4 inch
Margin = margin
};
Border border =new Border();
border.Child = ellipse;
args.PageVisual = border;
}
PageVisual 对象
没有与打印机相关联的特殊图形方法或图形类。 您可以按照在视频显示器上“绘制”对象的相同方式在打印机页面上“绘制”对象,方法是组合从 FrameworkElement 派生的对象可视树。 此树可以包含面板元素,其中包括画布。 若要打印该可视树,请将最上面的元素设置为 PrintPageEventArgs 的 PageVisual 属性。 (PageVisual 定义为 UIElement,该元素是 FrameworkElement 的父类,但实际上,将设置为 PageVisual 的一切对象都将从 FrameworkElement 派生。)
出于布局的目的,从 FrameworkElement 派生的几乎所有类都包含 MeasureOverride 和 ArrangeOverride 方法的重要实现。 在类的 MeasureOverride 方法中,有一个元素决定类的所需大小,有时通过调用子项的 Measure 方法来确定子项的所需大小。 在 ArrangeOverride 方法中,有一个元素通过调用子项的 Arrange 方法来排列子项相对于类本身的位置。
将某个元素设置为 PrintPageEventArgs 的 PageVisual 属性时,Silverlight 打印系统将使用 PrintableArea 大小在该最上面的元素上调用 Measure。 这就是(举例来说)Ellipse 或 Border 的大小自动调整为页面的可打印区域的方式。
但是,您也可以将该 PageVisual 属性设置为已属于程序窗口中所显示的可视树的元素。 这种情况下,打印系统不会对该元素调用 Measure,而是使用已为视频显示器确定的度量和布局。 这可使您在从程序窗口打印内容时保持合理的保真度,还意味着所打印的内容可能裁剪为页面大小。
当然,您可以对打印的元素设置明确的 Width 和 Height 属性,并且可以使用 PrintableArea 大小来帮助解决问题。
缩放和旋转
我要探讨的下一个程序比我预期更具挑战性。 目标是存储在用户本地计算机上允许用户打印 Silverlight 支持的任何图像文件(即 PNG 和 JPEG 文件)的程序。 此程序使用 OpenFileDialog 类加载这些文件。 出于安全考虑,OpenFileDialog 仅返回让程序打开文件的 FileInfo 对象。 不提供文件名或目录。
我希望此程序在页面(不包括预设边距)上尽可能大地打印位图,而不改变位图的长宽比。 通常情况下,这非常简单:Image 元素的默认 Stretch 模式为 Uniform,这意味着位图将被尽可能大地拉伸而不会扭曲。
但是,我决定不需要用户在相应打印机上针对特殊图像专门设置纵向或横向模式。 如果打印机设置为纵向模式,且图像的宽度大于高度,则我希望图像在纵向页面上横着打印。 这个小功能立即会使程序更复杂。
如果我编写一个实现此功能的 WPF 程序,程序本身可将打印机切换为纵向或横向模式。 但是这在 Silverlight 中无法实现。 打印机接口的定义使只有用户可以更改此类设置。
同样,如果我编写 WPF 程序,则可以对 Image 元素设置 LayoutTransform 以将其旋转 90 度。 随后将调整旋转后的 Image 元素的大小以适合页面,而且位图本身也会调整大小以适合该 Image 元素。
但是 Silverlight 不支持 LayoutTransform。 Silverlight 仅支持 RenderTransform,因此如果必须旋转 Image 元素以适合在纵向模式下打印的横向图像,还必须将 Image 元素的大小手动调整为横向页面的尺寸。
您可以在 bit.ly/eMHOsB 上试验我最初的尝试。 OnPrintPage 方法创建一个 Image 元素并将 Stretch 属性设置为 None,这意味着 Image 元素按位图的像素大小显示位图,这在打印机上意味着每个像素假定为 1/96 英寸。 程序然后将旋转该 Image 元素、调整其大小,并通过计算适用于该 Image 元素的 RenderTransform 属性的变换来转换该元素。
此类代码的难点当然是数学计算,因此,看到该程序可以在打印机设置为纵向和横向模式的情况下处理纵向和横向图像,将是一件非常高兴的事。
但是,如果由于图像太大而导致程序失败,又将是非常不愉快的事。 您可以亲自试验尺寸(除以 96 时)稍大于页面大小(以英寸为单位)的图像。 图像将按正确大小显示,但显示不完整。
这行代码起什么作用呢? 哦,我以前在视频显示器上看到过该代码。 请记住,RenderTransform 仅影响元素的显示方式,不影响元素在布局系统上的外观。 对于布局系统,我在 Stretch 设置为 None 的 Image 元素中显示了一个位图,意味着 Image 元素与位图本身一样大。 如果位图大于打印机页面,则无法呈现 Image 元素的某些部分,实际上将剪辑该元素,而与相应缩小 Image 元素的 RenderTransform 无关。
我的第二个尝试(您可在 bit.ly/g4HJ1C 上试用)采取了不同的策略。 图 3 中显示了 OnPrintPage 方法。 为 Image 元素提供了显式 Width 和 Height 设置,使该元素正好符合计算的显示区域的大小。 由于该元素全部位于页面的可打印区域中,因此不会剪辑任何内容。 Stretch 模式设置为 Fill,这意味着不论长宽比如何,位图都将填充该 Image 元素。 如果不旋转 Image 元素,正确调整了一个尺寸的大小,另一个尺寸必须应用可减小大小的比例因子。 如果还必须旋转 Image 元素,则这些比例因子必须适合旋转后 Image 元素的不同长宽比。
图 3 在 PrintImage 中打印图像
{
// Find the full size of the page
Size pageSize =
new Size(args.PrintableArea.Width
+ args.PageMargins.Left + args.PageMargins.Right,
args.PrintableArea.Height
+ args.PageMargins.Top + args.PageMargins.Bottom);
// Get additional margins to bring the total to MARGIN (= 96)
Thickness additionalMargin =new Thickness
{
Left = Math.Max(0, MARGIN - args.PageMargins.Left),
Top = Math.Max(0, MARGIN - args.PageMargins.Top),
Right = Math.Max(0, MARGIN - args.PageMargins.Right),
Bottom = Math.Max(0, MARGIN - args.PageMargins.Bottom)
};
// Find the area for display purposes
Size displayArea =
new Size(args.PrintableArea.Width
- additionalMargin.Left - additionalMargin.Right,
args.PrintableArea.Height
- additionalMargin.Top - additionalMargin.Bottom);
bool pageIsLandscape = displayArea.Width > displayArea.Height;
bool imageIsLandscape = bitmap.PixelWidth > bitmap.PixelHeight;
double displayAspectRatio = displayArea.Width / displayArea.Height;
double imageAspectRatio = (double)bitmap.PixelWidth / bitmap.PixelHeight;
double scaleX = Math.Min(1, imageAspectRatio / displayAspectRatio);
double scaleY = Math.Min(1, displayAspectRatio / imageAspectRatio);
// Calculate the transform matrix
MatrixTransform transform =new MatrixTransform();
if (pageIsLandscape == imageIsLandscape)
{
// Pure scaling
transform.Matrix =new Matrix(scaleX, 0, 0, scaleY, 0, 0);
}
else
{
// Scaling with rotation
scaleX *= pageIsLandscape ?
displayAspectRatio : 1/
displayAspectRatio;
scaleY *= pageIsLandscape ?
displayAspectRatio : 1/
displayAspectRatio;
transform.Matrix =new Matrix(0, scaleX, -scaleY, 0, 0, 0);
}
Image image =new Image
{
Source = bitmap,
Stretch = Stretch.Fill,
Width = displayArea.Width,
Height = displayArea.Height,
RenderTransform = transform,
RenderTransformOrigin =new Point(0.5, 0.5),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Margin = additionalMargin,
};
Border border =new Border
{
Child = image,
};
args.PageVisual = border;
}
代码实在是太杂乱了(我怀疑可能有一些简化,但对我来说不是很明显),但它适合所有大小的位图。
另一种方法是旋转位图本身而不是 Image 元素。 从加载的 BitmapImage 对象创建一个 WriteableBitmap,并使用交换的水平尺寸和垂直尺寸创建另一个 WritableBitmap。 然后将第一个 WriteableBitmap 中的所有像素复制到具有交换的行和列的第二个 WriteableBitmap。
多个日历页面
在 Silverlight 编程中,从 UserControl 派生是一项相当常用的技术,可用来创建可重用控件而不会增加很多麻烦。 UserControl 的大部分是在 XAML 中定义的可视树。
还可以通过从 UserControl 派生来定义用于打印的可视树! PrintCalendar 程序中阐释了此技术,您可以在 bit.ly/dIwSsn 上进行试验。 输入开始月份和结束月份后,程序将打印该范围中的所有月份,一个月份打印一页。 您可以将页面装订为挂历并进行标记,就好像真实的日历挂历一样。
体验 PrintImage 程序后,我不想为边距或方向而费心;我增加了一个按钮,通过它将此职责交给了用户,如图 4 所示。
图 4 PrintCalendar 按钮
定义日历页面的 UserControl 称为 CalendarPage,图 5 中显示了 XAML 文件。 顶部附近的 TextBlock 显示月份和年份。 然后是另一个网格,其中包含七列(用于表示星期几)和六行(用于表示一月中的最多六周或部分 周)。
图 5 CalendarPage 布局
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
FontSize="36">
<Grid x:Name="LayoutRoot" Background="White">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Name="monthYearText"
Grid.Row="0"
FontSize="48"
HorizontalAlignment="Center"/>
<Grid Name="dayGrid"
Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
</Grid>
</Grid>
</UserControl>
与大多数 UserControl 派生项不同,CalendarPage 定义了一个包含参数的构造函数,如图 6 所示。
图 6 CalendarPage 代码隐藏构造函数
{
InitializeComponent();
monthYearText.Text = date.ToString("MMMM yyyy");
int row =0;
int col = (int)new DateTime(date.Year, date.Month, 1).DayOfWeek;
for (int day =0; day < DateTime.DaysInMonth(date.Year, date.Month); day++)
{
TextBlock txtblk =new TextBlock
{
Text = (day +1).ToString(),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top
};
Border border =new Border
{
BorderBrush = blackBrush,
BorderThickness =new Thickness(2),
Child = txtblk
};
Grid.SetRow(border, row);
Grid.SetColumn(border, col);
dayGrid.Children.Add(border);
if (++col ==7)
{
col =0;
row++;
}
}
if (col ==0)
row--;
if (row <5)
dayGrid.RowDefinitions.RemoveAt(0);
if (row <4)
dayGrid.RowDefinitions.RemoveAt(0);
}
该参数是 DateTime 类型,构造函数使用 Month 和 Year 属性创建一个边框,其中包含月份中每一天的 TextBlock。 每个 TextBlock 都被分配了一个 Grid.Row 和 Grid.Column 附加属性,然后添加到网格中。 如您所知,月份通常跨越五周,有时候二月仅有四周,因此,如果不需要 RowDefinition 对象,实际上会将它们从网格中删除。
UserControl 派生项通常不具有包含参数的构造函数,因为它们通常构成大型可视树的部分。 但是,CalendarPage 的使用并非如此。 实际上,PrintPage 处理程序只是将 CalendarPage 的新实例分配给 PrintPageEventArgs 的 PageVisual 属性。 下面是该处理程序的完整主体,清晰地阐释了 CalendarPage 执行的工作量:
args.HasMorePages = dateTime < dateTimeEnd;
dateTime = dateTime.AddMonths(1);
因此,向程序中添加打印选项经常被视为涉及大量代码的令人精疲力尽的工作。 能够在 XAML 文件中定义大部分打印页面使整个工作变得不那么可怕。