在日常的WPF应用开发中,打印功能是一个既常见又容易让人头疼的需求。无论是打印报表、单据还是复杂的图形界面,我们总希望程序能像Word或Excel那样,灵活地控制打印内容和样式。今天,我们就来深入聊聊在WPF中如何实现打印功能,特别是如何打造自定义的打印模板,并实现一个用户友好的打印预览。这个过程就像是为你的应用程序定制专属的“打印机驱动程序”,虽然听起来复杂,但跟着步骤走,你会发现它其实有章可循。

一、WPF打印的核心:PrintDialog与Visual对象

在WPF中,打印的核心其实非常直观。整个打印流程可以概括为:获取要打印的“内容”,然后通过系统打印对话框发送到打印机。这个“内容”在WPF里通常就是一个Visual对象,它可以是任何从Visual类派生的元素,比如一个Window、一个UserControl,或者一个精心绘制的Canvas

实现打印的起点是System.Windows.Controls.PrintDialog类。它封装了与用户交互(选择打印机、设置份数等)和最终将内容发送到打印队列的工作。

让我们先看一个最基础的打印示例。假设我们有一个简单的窗口,里面有一个按钮和一个文本框,点击按钮就打印整个窗口。

// 技术栈:.NET Framework 4.8 / .NET Core 3.1+ 的 WPF
using System.Windows;
using System.Windows.Controls;

namespace WpfPrintDemo
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        // 按钮点击事件:打印当前窗口
        private void PrintButton_Click(object sender, RoutedEventArgs e)
        {
            // 1. 创建打印对话框实例
            PrintDialog printDialog = new PrintDialog();

            // 2. 显示打印设置对话框,如果用户点击了“确定”
            if (printDialog.ShowDialog() == true)
            {
                try
                {
                    // 3. 将当前窗口(一个Visual对象)作为打印内容发送到打印机
                    // 第一个参数是打印的Visual对象,第二个参数是打印作业的描述
                    printDialog.PrintVisual(this, "我的第一个WPF打印任务");
                }
                catch (System.Exception ex)
                {
                    MessageBox.Show($"打印失败:{ex.Message}");
                }
            }
        }
    }
}

这个例子虽然简单,但揭示了WPF打印的本质:PrintVisual方法。然而,直接打印整个窗口往往不是我们想要的。窗口上有菜单、工具栏、状态栏,而我们可能只想打印其中数据网格的内容。这就引出了我们的下一个主题:如何分离出真正需要打印的“模板”。

二、构建自定义打印模板:分离UI与逻辑

自定义打印模板的精髓在于,我们不应该为了打印而修改主界面的UI,而应该专门为打印创建一份独立的“视图”。这份视图只包含打印所需的数据和布局,通常不包含交互控件。在WPF中,我们可以通过以下几种方式创建打印模板:

  1. 使用XAML定义固定的UserControl:适合布局固定、样式复杂的打印内容。
  2. 使用代码动态生成Visual对象:适合数据驱动、布局灵活多变的打印内容,比如流水账单。
  3. 使用FlowDocument:特别适合大段文本、分页排版要求高的场景,如合同、报告。

这里,我们重点介绍前两种,因为它们在业务系统中更常见。让我们设计一个打印“员工信息卡”的场景。假设我们有一个员工对象,需要将其信息以卡片形式打印出来。

首先,我们定义一个员工数据模型和专门用于打印的UserControl

// 技术栈:.NET Framework 4.8 / .NET Core 3.1+ 的 WPF
// 1. 数据模型
public class Employee
{
    public string Name { get; set; }
    public string EmployeeId { get; set; }
    public string Department { get; set; }
    public string Position { get; set; }
    public DateTime HireDate { get; set; }
    // 假设有一个照片的字节数组或路径
    public string PhotoPath { get; set; }
}
<!-- 2. 打印模板UserControl (EmployeeCardPrintTemplate.xaml) -->
<UserControl x:Class="WpfPrintDemo.PrintTemplates.EmployeeCardPrintTemplate"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             Width="300" Height="180">
    <!-- 定义了一个固定大小的卡片边框 -->
    <Border BorderBrush="Black" BorderThickness="1" CornerRadius="5" Padding="10" Background="White">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>

            <!-- 照片区域 -->
            <Border Grid.Row="0" Grid.RowSpan="4" Grid.Column="0" Width="80" Height="100"
                    BorderBrush="Gray" BorderThickness="1" Margin="0,0,10,0">
                <Image Source="{Binding PhotoPath}" Stretch="Uniform"/>
            </Border>

            <!-- 员工信息区域 -->
            <TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Name}" FontSize="16" FontWeight="Bold"/>
            <StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal">
                <TextBlock Text="工号:" FontWeight="SemiBold"/>
                <TextBlock Text="{Binding EmployeeId}"/>
            </StackPanel>
            <StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal">
                <TextBlock Text="部门/职位:" FontWeight="SemiBold"/>
                <TextBlock Text="{Binding Department}"/>
                <TextBlock Text=" / "/>
                <TextBlock Text="{Binding Position}"/>
            </StackPanel>
            <StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal">
                <TextBlock Text="入职日期:" FontWeight="SemiBold"/>
                <TextBlock Text="{Binding HireDate, StringFormat=yyyy-MM-dd}"/>
            </StackPanel>
        </Grid>
    </Border>
</UserControl>
// 3. 打印模板UserControl的后台代码 (EmployeeCardPrintTemplate.xaml.cs)
namespace WpfPrintDemo.PrintTemplates
{
    public partial class EmployeeCardPrintTemplate : UserControl
    {
        // 可以通过构造函数或依赖属性注入数据
        public EmployeeCardPrintTemplate(Employee employee)
        {
            InitializeComponent();
            // 将整个UserControl的数据上下文设置为员工对象
            this.DataContext = employee;
        }
    }
}

现在,我们有了一个美观的打印模板。接下来,我们需要在打印时使用它。

三、实现打印逻辑与打印预览

有了模板,打印就变成了:1. 用数据填充模板,生成一个Visual对象;2. 将这个对象发送给打印机。但在此之前,一个好的用户体验应该包含打印预览。WPF本身没有提供现成的打印预览控件,但我们可以巧妙地利用DocumentViewer控件来模拟,或者更直接地,将打印内容渲染到一个独立的预览窗口中。

3.1 打印单页内容

我们先实现一个打印服务类,它负责将模板实例化并执行打印。

// 技术栈:.NET Framework 4.8 / .NET Core 3.1+ 的 WPF
using System.Windows;
using System.Windows.Controls;
using WpfPrintDemo.PrintTemplates;

public class PrintService
{
    /// <summary>
    /// 打印单个员工卡片
    /// </summary>
    /// <param name="employee">员工数据</param>
    public static void PrintEmployeeCard(Employee employee)
    {
        PrintDialog printDialog = new PrintDialog();

        // 用户可以在此选择打印机、设置属性
        if (printDialog.ShowDialog() == true)
        {
            // 实例化我们的打印模板,并传入数据
            EmployeeCardPrintTemplate printTemplate = new EmployeeCardPrintTemplate(employee);

            // 关键步骤:必须调用Measure和Arrange,让WPF布局系统正确计算模板的大小和位置
            // 否则打印出来的内容可能位置错乱或大小异常
            printTemplate.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
            printTemplate.Arrange(new Rect(printTemplate.DesiredSize));

            // 现在这个printTemplate就是一个布局好的Visual对象,可以用于打印
            printDialog.PrintVisual(printTemplate, $"员工卡片 - {employee.Name}");
        }
    }
}

3.2 实现简单的打印预览

打印预览的核心是“所见即所得”。我们可以创建一个新的窗口,把要打印的模板内容显示在里面,让用户提前看到效果。

// 技术栈:.NET Framework 4.8 / .NET Core 3.1+ 的 WPF
// 打印预览窗口 (PrintPreviewWindow.xaml)
// 窗口XAML很简单,主要是一个用于承载内容的Grid和一个打印按钮
// 这里省略XAML,主要看后台逻辑

public partial class PrintPreviewWindow : Window
{
    private Visual _printContent;

    public PrintPreviewWindow(Visual printContent, string title = "打印预览")
    {
        InitializeComponent();
        this.Title = title;
        _printContent = printContent;

        // 将待打印的内容添加到预览区域的Grid中
        PreviewGrid.Children.Clear();
        if (printContent is UIElement uiElement)
        {
            PreviewGrid.Children.Add(uiElement);
        }
    }

    // 预览窗口中的打印按钮事件
    private void PrintButton_Click(object sender, RoutedEventArgs e)
    {
        PrintDialog printDialog = new PrintDialog();
        if (printDialog.ShowDialog() == true)
        {
            // 直接打印我们之前传入的Visual对象
            printDialog.PrintVisual(_printContent, this.Title);
        }
    }
}

在主窗口中,我们可以这样调用预览:

private void PreviewButton_Click(object sender, RoutedEventArgs e)
{
    // 假设从某个地方获取了员工数据
    Employee emp = GetCurrentEmployee();

    // 创建打印模板Visual
    EmployeeCardPrintTemplate printTemplate = new EmployeeCardPrintTemplate(emp);
    printTemplate.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
    printTemplate.Arrange(new Rect(printTemplate.DesiredSize));

    // 打开预览窗口
    PrintPreviewWindow previewWindow = new PrintPreviewWindow(printTemplate, $"预览 - {emp.Name}的工卡");
    previewWindow.ShowDialog();
}

3.3 处理多页打印

现实中的打印任务常常是多页的,比如打印一个员工列表。WPF提供了DocumentPaginator类来帮助我们处理分页。我们需要自定义一个DocumentPaginator,告诉打印机如何将数据分割到不同的页面上。

这是一个更高级的主题,其基本思路是:根据纸张大小和边距,计算每页能容纳多少内容(比如多少行数据),然后在GetPage方法中为每一页返回一个DocumentPage对象,这个对象包含了该页的Visual内容。

四、深入分析:场景、优缺点与避坑指南

应用场景

  • 业务单据打印:销售订单、发票、出库单等,格式固定,需要对齐精准。
  • 报表与图表打印:将数据可视化结果(如Chart图表)输出到纸质文档。
  • 证件卡片打印:如我们示例中的工卡、会员卡。
  • 标签打印:物流面单、产品标签,通常对排版和纸张类型有特殊要求。

技术优点

  1. 与WPF深度集成:可以直接打印任何WPF视觉元素,利用强大的数据绑定和样式模板,设计打印界面和设计程序UI体验一致。
  2. 灵活性高:自定义模板意味着你可以完全控制打印输出的每一个像素,实现任何复杂的设计。
  3. 利用系统打印对话框:无需处理不同打印机的驱动差异,PrintDialog提供了标准的用户界面和接口。

技术缺点与挑战

  1. 分页逻辑需手动实现:对于流式文档或列表数据,实现一个健壮、高效的分页器(DocumentPaginator)有一定复杂度,需要精确计算内容高度和页面剩余空间。
  2. 打印预览功能需自建:WPF未提供开箱即用的打印预览控件,需要开发者自行实现预览界面和交互。
  3. 性能考量:当需要打印的数据量极大(如成百上千页)时,一次性生成所有页面的Visual对象可能会消耗大量内存。需要考虑流式分页生成。
  4. DPI和缩放问题:屏幕DPI和打印机DPI可能不同,在设计固定尺寸模板时,要确保使用与设备无关的单位(WPF默认就是),并测试不同分辨率下的输出效果。

重要注意事项

  • MeasureArrange是必须的:在将动态创建的Visual对象用于打印或预览前,务必调用这两个方法,否则布局是未定义的,打印会出错。
  • 处理边距PrintDialog.PrintVisual方法打印的内容默认从页面左上角开始。如果需要边距,你需要在模板外部包裹一个带有MarginPadding的容器,或者更专业地,在打印时计算并应用变换(Transform)。
  • 使用固定尺寸:打印模板应使用明确的WidthHeight,或者通过Measure/Arrange确定最终尺寸,避免依赖父容器的尺寸。
  • 异步与用户体验:大型打印作业应放在后台线程执行,避免阻塞UI,同时向用户提供进度提示。

总结: 在WPF中实现打印,是一场从“数据”到“纸质视觉”的旅程。核心在于理解Visual对象作为打印媒介的本质,并掌握PrintDialog.PrintVisual这个基本方法。通过创建独立的自定义打印模板,我们将业务逻辑与展示逻辑分离,获得了极大的灵活性。虽然WPF在分页和预览方面需要开发者投入更多精力去构建,但这同时也意味着你可以打造出完全符合业务需求的、体验一流的打印功能。记住,良好的打印功能是专业桌面应用不可或缺的一部分,它能让你的软件在关键时刻(如需要纸质凭证时)显得更加可靠和强大。从简单的单页打印开始,逐步挑战多页复杂报表,你会逐渐掌握这门让数据跃然纸上的艺术。