搞 WPF 打印这块,很多人在项目里一开始都是直接用PrintDialog.PrintVisual()打印Visual,简单场景确实够用,可一旦碰上多页、复杂排版、客户要求预览“所见即所得”,这条路就走不通了。我用 XAML 做打印模板配合 XPS 中间层这套方案,在几个实际项目里反复打磨过,今天把这套完整思路、踩坑记录和可以直接抄的代码整理出来,给你一条能落地的技术路径。
这套方案的核心链路很简单:XAML 排版 → FixedDocument 组装多页 → 序列化成 XPS 文档 → 用 XpsDocumentViewer 预览 / PrintDialog 输出。它解决的核心问题有三个:一是把 WPF 里的任意 XAML 页面变成打印机认识的矢量页面;二是把内容拆成多页,而不是一刀切地打印一页;三是让用户先看电子预览再打印,避免浪费纸张。
这套东西主要适合两类人:一类是在做 ERP、MES、医疗、政务这类系统,经常要打印单据、报表、标签的 WPF 开发者;另一类是想搞明白 WPF 固定文档(FixedDocument)和 XPS 打印体系的新手。我会从方案对比讲起,再深入到代码实现和问题排查,尽量一次讲透。
1. 整体方案设计:为什么非要绕一圈走 XPS
1.1 直接打印 Visual 的问题在哪
很多同学最开始写的打印代码长这样:
PrintDialog dialog = new PrintDialog(); if (dialog.ShowDialog() == true) { dialog.PrintVisual(myGrid, "打印内容"); }这段代码在以下场景会出问题:
- 要打印的内容高度超过一张纸,它会直接截断,不会自动分页;
myGrid里的字体大小、边距、页面大小和实际纸张的对应关系,全靠 WPF 布局时碰巧算对;- 预览功能缺失,用户只能盲打,打印出来才知道哪里错位;
- 打印队列里看到的是纯字节流,没法存储、归档、二次查看。
PrintVisual适合打印一屏内容恰好就是一页的极简单场景,但凡涉及多页、模板化、预览,它就不是正确选择。
1.2 XPS 作为打印中间层的优势
XPS(XML Paper Specification)本质上是一个 Zip 压缩包,里面用 XML 描述页面矢量内容。WPF 的打印体系原生就是围绕 XPS 设计的,打印到 XPS 文档几乎是零损耗。
你的 XAML 视觉树不需要变成位图,而是直接序列化成矢量元素,这意味着:
- 打印出来的文字、线条、Logo 是矢量,放大也不模糊;
- 文件体积小,相比转 PNG 再打印动辄几十 MB 的图,XPS 文档通常几百 KB;
- 可以把打印任务存成
.xps文件,归档、重打、发邮件都方便; - 内置的
XpsDocumentViewer控件天然适配 WPF,预览界面不用自己撸。
这套方案的实现路径是:
XAML 模板(FixedPage) → PageContent → FixedDocument → XpsDocumentWriter 序列化 → .xps 文件 ├→ XpsDocumentViewer 预览 └→ PrintDialog 输出到真实打印机1.3 跟其他打印实现方式的横向对比
我在项目里评估过几条路线,这里列个对比表供你选型:
| 方案 | 多页支持 | 预览能力 | 排版精度 | 文件归档 | 开发成本 | 适用场景 |
|---|---|---|---|---|---|---|
| PrintVisual 直接打印 | 差 | 无 | 低 | 无 | 极低 | 临时打印,一页内容 |
| FlowDocument 打印 | 自动分页 | 无(需额外做) | 中 | 弱 | 低 | 纯文字长文,格式要求低 |
| 截图转图片再打印 | 手动拆 | 有 | 低(会糊) | 弱 | 中 | 对清晰度不敏感的简单记录 |
| 固定页面 + XPS 链路 | 手动精确控制 | 原生支持 | 高 | 强 | 中高 | 报表、单据、票据、证书 |
FlowDocument 我有段时间经常推荐,它自带分页能力,适合打印长文本,但问题是它排版自由度低,圆角表格、复杂表头、固定像素对齐这些需求很别扭。固定页面(FixedPage + FixedDocument)则是像素级精确控制,更适合业务单据。
所以我的结论很干脆:凡是涉及正式单据、报表、标签,直接上 FixedDocument + XPS 链路,前期多花一点时间,后面所有打印需求都能在这个框架里解决。
2. 核心概念拆解:FixedDocument、FixedPage 与 XpsDocument 的关系
2.1 三个容易混的概念
第一次接触这套体系,很多人会被FixedDocument、FixedPage、PageContent三个类绕晕。我打个比方:
XpsDocument是笔记本外壳;FixedDocument是笔记本里的纸张列表;PageContent是每张纸的占位夹;FixedPage是真正印着内容的纸面;- 而你的业务控件(
Grid、Border、Canvas)是贴到纸面上的一层层便签。
代码关系是这样的:
FixedDocument doc = new FixedDocument(); // 每一页 FixedPage page = new FixedPage(); page.Width = 793.44; // A4 宽,单位 1/96 英寸 page.Height = 1122.52; // A4 高 // 把可视化内容放进页面 page.Children.Add(content); PageContent pc = new PageContent(); ((IAddChild)pc).AddChild(page); doc.Pages.Add(pc);注意PageContent继承自FrameworkContentElement,它不是普通容器,添加FixedPage必须走IAddChild接口,不能直接pc.Child = page。这个语法坑了我两次,特意标出来。
2.2 尺寸单位与 DPI 的关系
WPF 的坐标系单位是 1/96 英寸。A4 纸是 210mm × 297mm,换算过来:
210mm = 210 / 25.4 * 96 ≈ 793.44 297mm = 297 / 25.4 * 96 ≈ 1122.52这两个数不要写成210*96/25.4硬算,直接记住 793.44 和 1122.52。这套尺寸是 FixedPage 的宽高,而不是打印机物理像素。WPF 渲染层会自动把这些 DIP 转换为打印机 DPI,不需要你手动乘 300 或 600。
真正要注意的是:FixedPage 的宽高决定了内容排版范围,打印机的物理输出范围由 PrintTicket 的 PageMediaSize 决定。两者必须协调,否则会出现预览正常但打印裁边,或者反过来留大片白边的情况。
2.3 多页打印的组装模式
多页有两种常见需求模式:
模式一:每页内容独立,比如合同、发票、证书
这种最简单,循环你准备好的页面集合,每页对应一个FixedPage,依次添加到FixedDocument.Pages。
模式二:一长串内容拆到多页,比如商品清单、多页报表
这种要先把数据切片,按每页固定行数或固定高度切成多个子集合,再为每个子集合生成一个排版后的 XAML 根元素,逐个塞进FixedPage。
我用的拆页逻辑通常是自上而下的,核心思路是:定义一个页面的内容区高度,往里面填一行数据就累加高度,超过剩余高度就开新页。这个算法不复杂,后面我会给一个通用实现。
double pageContentHeight = 1122.52 - topMargin - bottomMargin; double currentHeight = 0; int pageIndex = 1; List<ItemsPanelPage> pages = new List<ItemsPanelPage>(); ItemsPanelPage currentPage = CreateNewPage(pageIndex); foreach (var item in dataList) { double itemHeight = EstimateItemHeight(item); if (currentHeight + itemHeight > pageContentHeight) { pages.Add(currentPage); currentPage = CreateNewPage(++pageIndex); currentHeight = 0; } currentPage.Children.Add(CreateItemElement(item)); currentHeight += itemHeight; } pages.Add(currentPage);这套思路的好处是拆页逻辑和 UI 完全解耦,数据量再大也只是一次循环。
2.4 控件放不进 FixedPage?包一层容器
很多人第一次写会遇到异常:FixedPage.Children的UIElementCollection要求所有子元素必须是Freezable或者Visual,你直接塞一个从ContentControl继承的复杂控件,往往没问题;但如果塞的是FrameworkElement派生的一些特殊控件,运行时会报错,说不能直接作为FixedPage的子元素。
稳妥的封装方式是给FixedPage加一个根容器,比如Grid或Border,然后业务内容往里放。我习惯这样做:
FixedPage page = new FixedPage(); page.Width = 793.44; page.Height = 1122.52; Grid root = new Grid(); root.Width = page.Width; root.Height = page.Height; root.Children.Add(content); page.Children.Add(root);这样所有控件都挂在root下面,规避了绝大部分类型限制问题,同时还能统一控制背景、边距、水印。
3. 实操实现:从零搭一套可复用的 XPS 打印预览代码
3.1 准备工作:工程引用与界面结构
使用XpsDocumentViewer需要项目引用ReachFramework.dll,这是 .NET Framework / .NET Core WPF 里专门处理 XPS 的托管程序集。
如果你用的是 .NET 6 及以上,创建 WPF 项目后,在csproj里加这一行也能拿到:
<ItemGroup> <Reference Include="ReachFramework" /> <Reference Include="System.Printing" /> </ItemGroup>在 .NET Core WPF 里是 SDK 隐式引用还是需要手动加,版本不同行为略有差异,报错就加,不报错就别动。
界面结构分两块:左侧业务操作区,右侧实时 XPS 预览。这样一打开就能看到模板效果:
<Window x:Class="XpsPrintDemo.MainWindow" Title="XPS 多页打印预览" Height="800" Width="1200"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="320"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <StackPanel Grid.Column="0" Margin="16"> <TextBlock Text="打印数据" FontWeight="Bold" FontSize="14"/> <TextBox x:Name="TxtData" Height="200" AcceptsReturn="True" Text="商品A|10|120
商品B|20|80
商品C|15|200" Margin="0,8,0,0"/> <Button Content="生成预览" Click="BtnPreview_Click" Margin="0,12,0,0" Padding="8,4"/> <Button Content="直接打印" Click="BtnPrint_Click" Margin="0,8,0,0" Padding="8,4"/> </StackPanel> <Border Grid.Column="1" BorderBrush="#DDD" BorderThickness="1" Margin="8"> <xps:DocumentViewer x:Name="DocViewer" Grid.Column="1"/> </Border> </Grid> </Window>DocumentViewer和XpsDocumentViewer属于同一命名空间,DocumentViewer可以直接显示FixedDocument,XpsDocumentViewer需要先序列化成 XPS 再打开文件流。两者的差别后面细说,我一般用DocumentViewer做即时预览,用XpsDocumentViewer做最终成品展示,这里先按标题要求实现成XpsDocumentViewer。
3.2 模板层:复用的 XAML 打印页面
打印页面的布局我建议用 XAML 写成一个UserControl,这是模板化的最佳实践。以一张业务单据为例:顶部是标题,下面表格,底部签名区。这样一个 UserControl 接收数据对象,设置好DataContext后就是一个自包含的页面。
<UserControl x:Class="XpsPrintDemo.Controls.ReceiptPage" Width="793.44" Height="1122.52"> <Border BorderBrush="Black" BorderThickness="1" Margin="40"> <StackPanel> <TextBlock x:Name="TitleText" Text="收款凭证" FontSize="28" FontWeight="Bold" HorizontalAlignment="Center" Margin="0,20,0,0"/> <Border Height="40" Background="#F2F2F2" Margin="20,20,20,0" CornerRadius="2"> <Grid> <TextBlock VerticalAlignment="Center" Margin="10,0" Text="{Binding ReceiptNo}" FontWeight="SemiBold"/> </Grid> </Border> <ItemsControl x:Name="DetailList" Margin="20,16,20,0"> <ItemsControl.ItemTemplate> <DataTemplate> <Grid Margin="0,4,0,4"> <Grid.ColumnDefinitions> <ColumnDefinition Width="2*"/> <ColumnDefinition Width="1*"/> <ColumnDefinition Width="1*"/> </Grid.ColumnDefinitions> <TextBlock Text="{Binding Name}" Grid.Column="0"/> <TextBlock Text="{Binding Quantity}" Grid.Column="1" HorizontalAlignment="Right"/> <TextBlock Text="{Binding Amount}" Grid.Column="2" HorizontalAlignment="Right"/> </Grid> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> <Border Height="1" Background="Black" Margin="20,12,20,0"/> <TextBlock Text="合计" HorizontalAlignment="Right" Margin="0,12,30,0" FontWeight="Bold"/> <TextBlock FontStyle="Italic" Text="备注:已收到上述款项,款项来源真实有效。" Margin="20,30,20,0" TextWrapping="Wrap"/> </StackPanel> </Border> </UserControl>这个 UserControl 的宽高就是 A4 尺寸,所有内容在 XAML 设计器里就能肉眼对齐,所见即所得。
3.3 组装 FixedDocument 的核心方法
下面是生成多页FixedDocument的核心方法。这段代码是整套方案的骨架,你直接复制改一改数据结构就能跑。
private FixedDocument BuildFixedDocument(List<PrintData> pages) { FixedDocument document = new FixedDocument(); document.DocumentPaginator.PageSize = new Size(793.44, 1122.52); foreach (var item in pages) { FixedPage fixedPage = new FixedPage(); fixedPage.Width = 793.44; fixedPage.Height = 1122.52; // 创建你的页面内容 ReceiptPage pageContent = new ReceiptPage(); pageContent.DataContext = item; // 包一层 Grid,方便后续扩展 Grid root = new Grid(); root.Width = 793.44; root.Height = 1122.52; root.Children.Add(pageContent); fixedPage.Children.Add(root); PageContent pc = new PageContent(); ((IAddChild)pc).AddChild(fixedPage); document.Pages.Add(pc); } return document; }一个小细节:DocumentPaginator.PageSize设置不设置,取决于你是否后续调用GetPage等分页器方法。打印时PrintDialog会读DocumentPaginator,所以建议最好给它指定正确尺寸,避免打印机拿到的页码大小和页面实际宽度不一致。
3.4 序列化 XPS 文档与 XpsDocumentViewer 预览
要把FixedDocument转成XpsDocument,需要XpsDocumentWriter,而它要从XpsDocument拿到PackageStore相关的写入管道。
private XpsDocument SaveToXps(FixedDocument document, string filePath) { // 删除旧文件,否则 PackageStore 会报冲突 if (File.Exists(filePath)) { File.Delete(filePath); } XpsDocument xpsDoc = new XpsDocument(filePath, FileAccess.ReadWrite); XpsDocumentWriter writer = XpsDocument.CreateXpsDocumentWriter(xpsDoc); writer.Write(document); return xpsDoc; }把它和预览绑定起来:
private void PreviewDocument(FixedDocument doc) { string tempFile = Path.Combine(Path.GetTempPath(), $"preview_{Guid.NewGuid():N}.xps"); XpsDocument xpsDoc = SaveToXps(doc, tempFile); // 先关闭旧的预览文档 if (_currentXpsDoc != null) { _currentXpsDoc.Close(); _currentXpsDoc = null; } _currentXpsDoc = xpsDoc; DocViewer.Document = xpsDoc.GetFixedDocumentSequence(); }注意GetFixedDocumentSequence()返回的是只读流视图,DocViewer.Document拿到它后,不要立刻关闭 XpsDocument,否则界面空白。这里我缓存了一个_currentXpsDoc字段,在下次预览或者窗口关闭时再释放。
3.5 调用系统打印对话框输出
从 XPS 预览到真实打印机,复用同一个FixedDocument就够:
private void PrintDocument(FixedDocument doc) { PrintDialog dialog = new PrintDialog(); if (dialog.ShowDialog() == true) { // 设置按内容调整纸张 dialog.PrintTicket.PageMediaSize = new PageMediaSize(dialog.PrintableAreaWidth > 793.44 ? 793.44 : dialog.PrintableAreaWidth, dialog.PrintableAreaHeight > 1122.52 ? 1122.52 : dialog.PrintableAreaHeight); dialog.PrintDocument(doc.DocumentPaginator, "我的业务单据"); } }这里有个务实的做法:Dialog.PrintableAreaWidth/Height是打印机能用到的物理尺寸,如果不是 A4 完全匹配,我做个 min 限制,防止内容溢出。真要精确匹配,应该根据doc.DocumentPaginator.PageSize设置PageMediaSize,打印机的驱动才会按比例缩放。
3.6 多页长列表的整体打印流程
把前面几段串成一个完整的点击事件:
private void BtnPreview_Click(object sender, RoutedEventArgs e) { var dataList = ParseInputData(TxtData.Text); var splittedPages = SplitPages(dataList, 12); // 每页最多 12 行明细 FixedDocument doc = BuildFixedDocument(splittedPages); PreviewDocument(doc); } private void BtnPrint_Click(object sender, RoutedEventArgs e) { var dataList = ParseInputData(TxtData.Text); var splittedPages = SplitPages(dataList, 12); FixedDocument doc = BuildFixedDocument(splittedPages); PrintDocument(doc); }拆分明细的SplitPages其实不复杂,关键是行高估算。如果每条明细是一个固定高度的控件模板,那行高是常数,直接整除即可;如果行高不固定,保守起见用估算行高加上边距余量来切,宁可多切一页,也不要内容被吃掉。
4. 常见问题与排查技巧实录
4.1 打印出来内容被截断或缩放变形
这个现象我排查过很多次,本质是 FixedPage 尺寸和打印机实际打印区不匹配。打印机物理打印区一般是 210mm 减去驱动保留边距,但 WPF 布局时 FixedPage 是假想 793×1122 无边距页面,导致打印机做“适应页面”缩放。
处理思路有三种:
- FixedPage 尺寸直接用
PrintDialog.PrintableAreaWidth / Height动态生成; - 在 PrintTicket 里显式指定
PageMediaSize,并允许缩放; - 模板里留出足够安全边距(我通常左右留 48 单位,上下留 50 单位)。
我用第三种最多,因为业务单据模板是固定的,留安全边距最省事。
4.2 预览正常但打印缺少最外层边框
很多模板外层 Border 的 BorderThickness 设为 1,A4 缩放下 1 像素会被打印驱动淡化甚至丢失。解决方案是外层边框设 2 或 3,或者用矩形线条来画边界。
实测 A4 打印的物理墨迹扩散和屏幕显示有较大差异,1 像素的边框经常若隐若现,这个问题只会在打印机输出后暴露,预览永远发现不了。
4.3 XPS 文件被占用无法再次覆盖
XpsDocument打开后不释放,文件就会被PackageStore锁住。第二次生成同名文件时直接抛System.IO.FileNotFoundException或UnauthorizedAccessException。
建议每次预览都用 Guid 文件名,并且统一管理生命周期。还要注意一点:如果给XpsDocumentViewer赋过Document,仅调用xpsDoc.Close()不一定立即释放文件句柄,需要把Document置空,再packageStore.RemovePackage(如果用到了PackageStore)。
4.4 多页打印时第二页以后出现少量偏移
这个偏移动辄几个像素,露馅的是页边距。FixedPage 每页都设置Width/Height,但PageContent的Left/Top没有显式设 0,部分环境会有默认偏移。
保险做法:
FixedPage.SetLeft(page, 0); FixedPage.SetTop(page, 0);或者直接在PageContent上设置Child再强制布局。
4.5 中文字体在虚拟打印机上呈现异常
XPS 序列化时字体的处理机制是:对于已安装的系统字体,XPS 里保存的是字体引用;如果目标环境没有字体,RIP 时会用替代字体渲染。PDF 打印机(比如某些虚拟 PDF 驱动)截获时就会抓瞎。
解决方法是显式指定字体:
TextOptions.SetTextFormattingMode(titleText, TextFormattingMode.Display);同时把模板中的FontFamily锁死为你必然存在的字体,比如“微软雅黑”。如果部署到瘦客户端,注意字体缺失问题,这种环境连微软雅黑都可能被精简掉。
4.6XpsDocumentViewer工具栏是英文,怎么处理
XpsDocumentViewer自带工具栏默认跟随系统区域设置,但在某些服务器系统上会显示英文。你可以不用它的内置工具栏,在外部放自己的“上一页 / 下一页 / 缩放”按钮,去操作XpsDocumentViewer内的DocumentViewer面板。追根究底,XpsDocumentViewer内部也有DocumentViewer的翻页逻辑,但对外接口有限。
搭配按钮控制代码:
// 在你的自定义按钮事件里调用 DocViewer.GetType().GetProperty("DocumentViewer")?.GetValue(DocViewer);这属于反射偷鸡,不推荐做核心功能,但对某些客户场景确实有效。
4.7 页面内容巨大导致预览卡顿
几百页的报表一次性序列化到 XPS,内存和 UI 冻结是必然的。优化角度有三条:
- 后台线程生成
FixedDocument,完成后切回 UI 线程绑定预览; - 分批发页,一次只组装当前可见页(配合虚拟化,代价是 XpsDocument 写入本质是按顺序写,真正的大文档可能需要流式写入);
- 尽量复用页面模板实例,避免每页重新解析 XAML 视觉树。
第 3 条值得多说一句:如果你每一页只是数据不同,模板结构完全一样,可以预先创建若干ReceiptPage实例池,但设置 DataContext 后务必调用UpdateLayout()再塞进 FixedPage,否则绑定值可能来不及生效,页面显示空白。
pageContent.UpdateLayout();这个UpdateLayout()在生成 XPS 时非常关键。我遇到过好多次预览畅通,一旦打出来表格某列空白,就是少了强制布局。
5. 从单页到多页的可复用扩展建议
5.1 把打印需求抽象成“打印作业”
在实际项目中,“打印”不是一个孤立操作,而是整条业务链路的一环。我建议抽象出三个概念:
IPrintPageBuilder:负责把业务数据变成Visual/FixedPage;IReportDataProvider:提供分页前的数据源;PrintJobCoordinator:统一调度生成、预览、打印、归档。
这样加一种单据类型,只需加一个 Builder,不用动打印框架本身。
5.2 把 XPS 归档纳入流程
用 XPS 做中间层还有个隐藏福利:可以无痛实现“打印什么存什么”。每次打印完成后,把生成的.xps文件存到文件服务器或数据库,后续要追溯、补打,直接把旧 XPS 打开重新打印即可。这比存 PDF 少了格式转换成本,比存数据库大字段更轻量。
我在某个供应链项目里就是这样做的:打印记录表和 XPS 附件一一对应,补打印只需要定位记录、打开 XPS、调 PrintDialog,效果和重跑业务逻辑完全一致,还不会因为业务数据结构变化导致旧单打不出来。
5.3 水印、条码、电子签名的加入方式
这套框架扩展起来非常容易。加水印,在FixedPage上叠加一个半透明 TextBlock,设置RenderTransform旋转 45 度即可。加条码,用ZXing.Net生成BitmapSource,再放进Image控件,位置随意控制。
FixedPage page = new FixedPage(); page.Width = 793.44; page.Height = 1122.52; Image barcode = new Image(); RenderOptions.SetBitmapScalingMode(barcode, BitmapScalingMode.NearestNeighbor); barcode.Source = GenerateBarcodeSource("A123456789"); barcode.Width = 200; barcode.HorizontalAlignment = HorizontalAlignment.Right; page.Children.Add(barcode);水印和条码的打印位置、大小、旋转角度都能精确定点,这是 XPS 链路相对 FlowDocument 的巨大优势。
6. 最后的经验之谈
做 WPF 打印这几年,我最大的心得是:不要在“看起来能打印”和“打出来是标准的”之间妥协。直接打印 Visual 的代码五分钟就能写出来,但后面客户一句“为什么第二页没有边线”就能让你排查一下午。走 XPS 链路,前期多写一次序列化逻辑,换来的是整套打印体系的稳定和可控。
最后分享一个小技巧:调试打印模板时,建议先在文档里加一页“排版本”,也就是一个 FixedPage,画好页面四周边线、中线、出血线,打印出来用尺子量,你就知道你的模板在真实纸张上的实际偏移量。然后回到模板里把这套偏移量补偿进去,一次校准,后续所有页全部对齐。这一步做完,你能省掉很多和打印机的爱恨情仇。
如果你现在正准备接 WPF 打印需求,别犹豫,直接按 FixedDocument + XPS + XpsDocumentViewer 这条路走。它稍微绕一点,但绕的每一步都在为后面铺路。