WPF打印方案:基于XPS与FixedDocument实现多页预览与输出
搞 WPF 打印这块很多人在项目里一开始都是直接用PrintDialog.PrintVisual()打印Visual简单场景确实够用可一旦碰上多页、复杂排版、客户要求预览“所见即所得”这条路就走不通了。我用 XAML 做打印模板配合 XPS 中间层这套方案在几个实际项目里反复打磨过今天把这套完整思路、踩坑记录和可以直接抄的代码整理出来给你一条能落地的技术路径。这套方案的核心链路很简单XAML 排版 → FixedDocument 组装多页 → 序列化成 XPS 文档 → 用 XpsDocumentViewer 预览 / PrintDialog 输出。它解决的核心问题有三个一是把 WPF 里的任意 XAML 页面变成打印机认识的矢量页面二是把内容拆成多页而不是一刀切地打印一页三是让用户先看电子预览再打印避免浪费纸张。这套东西主要适合两类人一类是在做 ERP、MES、医疗、政务这类系统经常要打印单据、报表、标签的 WPF 开发者另一类是想搞明白 WPF 固定文档FixedDocument和 XPS 打印体系的新手。我会从方案对比讲起再深入到代码实现和问题排查尽量一次讲透。1. 整体方案设计为什么非要绕一圈走 XPS1.1 直接打印 Visual 的问题在哪很多同学最开始写的打印代码长这样PrintDialog dialog new PrintDialog(); if (dialog.ShowDialog() true) { dialog.PrintVisual(myGrid, 打印内容); }这段代码在以下场景会出问题要打印的内容高度超过一张纸它会直接截断不会自动分页myGrid里的字体大小、边距、页面大小和实际纸张的对应关系全靠 WPF 布局时碰巧算对预览功能缺失用户只能盲打打印出来才知道哪里错位打印队列里看到的是纯字节流没法存储、归档、二次查看。PrintVisual适合打印一屏内容恰好就是一页的极简单场景但凡涉及多页、模板化、预览它就不是正确选择。1.2 XPS 作为打印中间层的优势XPSXML 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; ListItemsPanelPage pages new ListItemsPanelPage(); 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 IncludeReachFramework / Reference IncludeSystem.Printing / /ItemGroup在 .NET Core WPF 里是 SDK 隐式引用还是需要手动加版本不同行为略有差异报错就加不报错就别动。界面结构分两块左侧业务操作区右侧实时 XPS 预览。这样一打开就能看到模板效果Window x:ClassXpsPrintDemo.MainWindow TitleXPS 多页打印预览 Height800 Width1200 Grid Grid.ColumnDefinitions ColumnDefinition Width320/ ColumnDefinition Width*/ /Grid.ColumnDefinitions StackPanel Grid.Column0 Margin16 TextBlock Text打印数据 FontWeightBold FontSize14/ TextBox x:NameTxtData Height200 AcceptsReturnTrue Text商品A|10|120#x0a;商品B|20|80#x0a;商品C|15|200 Margin0,8,0,0/ Button Content生成预览 ClickBtnPreview_Click Margin0,12,0,0 Padding8,4/ Button Content直接打印 ClickBtnPrint_Click Margin0,8,0,0 Padding8,4/ /StackPanel Border Grid.Column1 BorderBrush#DDD BorderThickness1 Margin8 xps:DocumentViewer x:NameDocViewer Grid.Column1/ /Border /Grid /WindowDocumentViewer和XpsDocumentViewer属于同一命名空间DocumentViewer可以直接显示FixedDocumentXpsDocumentViewer需要先序列化成 XPS 再打开文件流。两者的差别后面细说我一般用DocumentViewer做即时预览用XpsDocumentViewer做最终成品展示这里先按标题要求实现成XpsDocumentViewer。3.2 模板层复用的 XAML 打印页面打印页面的布局我建议用 XAML 写成一个UserControl这是模板化的最佳实践。以一张业务单据为例顶部是标题下面表格底部签名区。这样一个 UserControl 接收数据对象设置好DataContext后就是一个自包含的页面。UserControl x:ClassXpsPrintDemo.Controls.ReceiptPage Width793.44 Height1122.52 Border BorderBrushBlack BorderThickness1 Margin40 StackPanel TextBlock x:NameTitleText Text收款凭证 FontSize28 FontWeightBold HorizontalAlignmentCenter Margin0,20,0,0/ Border Height40 Background#F2F2F2 Margin20,20,20,0 CornerRadius2 Grid TextBlock VerticalAlignmentCenter Margin10,0 Text{Binding ReceiptNo} FontWeightSemiBold/ /Grid /Border ItemsControl x:NameDetailList Margin20,16,20,0 ItemsControl.ItemTemplate DataTemplate Grid Margin0,4,0,4 Grid.ColumnDefinitions ColumnDefinition Width2*/ ColumnDefinition Width1*/ ColumnDefinition Width1*/ /Grid.ColumnDefinitions TextBlock Text{Binding Name} Grid.Column0/ TextBlock Text{Binding Quantity} Grid.Column1 HorizontalAlignmentRight/ TextBlock Text{Binding Amount} Grid.Column2 HorizontalAlignmentRight/ /Grid /DataTemplate /ItemsControl.ItemTemplate /ItemsControl Border Height1 BackgroundBlack Margin20,12,20,0/ TextBlock Text合计 HorizontalAlignmentRight Margin0,12,30,0 FontWeightBold/ TextBlock FontStyleItalic Text备注已收到上述款项款项来源真实有效。 Margin20,30,20,0 TextWrappingWrap/ /StackPanel /Border /UserControl这个 UserControl 的宽高就是 A4 尺寸所有内容在 XAML 设计器里就能肉眼对齐所见即所得。3.3 组装 FixedDocument 的核心方法下面是生成多页FixedDocument的核心方法。这段代码是整套方案的骨架你直接复制改一改数据结构就能跑。private FixedDocument BuildFixedDocument(ListPrintData 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 设为 1A4 缩放下 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/FixedPageIReportDataProvider提供分页前的数据源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 这条路走。它稍微绕一点但绕的每一步都在为后面铺路。