ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WPF打印方案:基于XPS与FixedDocument实现多页预览与输出

WPF打印方案:基于XPS与FixedDocument实现多页预览与输出

搞 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&#x0a;商品B|20|80&#x0a;商品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 无边距页面,导致打印机做“适应页面”缩放。

处理思路有三种:

  1. FixedPage 尺寸直接用PrintDialog.PrintableAreaWidth / Height动态生成;
  2. 在 PrintTicket 里显式指定PageMediaSize,并允许缩放;
  3. 模板里留出足够安全边距(我通常左右留 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 冻结是必然的。优化角度有三条:

  1. 后台线程生成FixedDocument,完成后切回 UI 线程绑定预览;
  2. 分批发页,一次只组装当前可见页(配合虚拟化,代价是 XpsDocument 写入本质是按顺序写,真正的大文档可能需要流式写入);
  3. 尽量复用页面模板实例,避免每页重新解析 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 这条路走。它稍微绕一点,但绕的每一步都在为后面铺路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进