
简介一份面向WPF开发人员的打印与预览功能示例重点解决在应用中打印图标、图像及自定义UI内容的需求。压缩包内含43个文件以C#源码、XAML界面、项目配置及可运行exe为主附带pdb调试符号与资源文件整体约78KB便于直接打开工程查看实现细节。已有310人学习下载。示例工程涵盖PrintDialog调用、FixedDocument构造、Visual视觉打印及XPS输出等关键环节通过具体代码演示如何将图标、位图乃至地图内容准确呈现到预览与纸质输出中同时兼顾了打印分辨率、颜色透明度和多页面组织等实际问题。适合正在学习WPF打印机制或需要快速为项目接入打印功能的初中级开发人员。1. WPF 打印与预览先分清你要的是出图还是出纸做 WPF 客户端的人迟早会遇到打印需求而且多半是从一个看似简单的问题开始的对方给了一份模板要求把业务数据填进去既能屏幕预览又能一点打印输出——顺便还要把公司 Logo 图标一并打出来。如果你直接拿PrintDialog改两下就交差十有八九会翻车因为 WPF 打印体系里的打印和预览是两个层次的东西底层是PrintDialogVisual的快速通道适合整块界面原样输出而真正可控的分页、页边距、逐页预览得靠DocumentPaginator把内容转成文档再送进PrintQueue。这份资源提供的是一个可复现的 WPF 打印与预览实验项目覆盖了这两种模式的切换、图标打印的 DPI 处理以及预览控件的封装思路。适合给刚接手 WPF 打印功能、或者被分页预览折磨过的开发者做参考照着跑一遍就能看出自己在哪个环节踩了坑。2. 底层机制先立住WPF 里打印不是只有一种写法2.1 三条技术路线的选型逻辑WPF 里实现打印常见做法其实就三条路。第一条是PrintDialog.PrintVisual(visual, description)把任何Visual对象直接送打印机屏幕上画的是什么就打印什么简单粗暴适合打印单页内容比如一张报表的截图或者一个画布的整体输出。第二条是用PrintDialog.PrintDocument(paginator, description)需要你自己实现一个DocumentPaginator来把内容切成多页适合多页报表、票据、带页码的文档。第三条是走FlowDocument的IDocumentPaginatorSource适合文本密集型内容但做表格和精细排版控制反而更麻烦我一般只在纯文字长文档时才用。这个项目围绕的是前两条路因为图标打印和预览都要落在可控的Visual输出上。资源目录里核心代码主要有自定义分页器PrintAblePaginator负责把多个Visual按纸张尺寸排列成多页预览控件PrintPreviewControl把分页结果渲染到屏幕再加一个PrintHelper封装PrintDialog的操作。整体是个典型的业务 Visual 拆分 分页器转文档 预览和打印共用同一份分页数据的结构。理解了这三者的关系后面的代码就好读了。2.2 PrintVisual 为什么适合单页、不适合多页先看最简单的一行代码能做什么// 直接把一个 Visual 输出到打印机 var dialog new PrintDialog(); if (dialog.ShowDialog() true) { dialog.PrintVisual(visual, 我的打印任务); }这段代码的逻辑很直白ShowDialog()弹出系统打印对话框用户选完打印机和份数后PrintVisual把传入的visual对象矢量发给驱动。注意这里的visual是Visual类型任何FrameworkElement比如Grid、Canvas、Border都可以直接传进去。但这条路的代价是你无法控制分页。假如一个 Grid 高度 1500 像素而 A4 纸在 96 DPI 下可打印区域大约是 1063 像素高打印机会把超出部分直接裁掉不会帮你翻页。另一个坑是PrintVisual不会触发重新布局如果你传的是一个不在可视树里挂载的元素它的ActualWidth和ActualHeight可能是 0打印出来就是空白。所以这套项目里PrintVisual只被用在单页图标输出的场景——比如把一个 Logo 画布单独打到标签纸上。多页业务数据走的是下文的DocumentPaginator路线。2.3 DocumentPaginator 与 PrintDocument 的正确姿势多页打印的核心不是 PrintDialog而是你自定义的分页器。基类长这样public class PrintAblePaginator : DocumentPaginator { private readonly ListVisual _pages new ListVisual(); private Size _pageSize; public PrintAblePaginator(ListVisual pages, Size pageSize) { _pages pages; _pageSize pageSize; } public override bool IsPageCountValid true; public override int PageCount _pages.Count; public override Size PageSize { get _pageSize; set _pageSize value; } public override IDocumentPaginatorSource Source null; public override DocumentPage GetPage(int pageNumber) { // 把对应页的 Visual 放进一个容器设置纸张尺寸返回 DocumentPage return new DocumentPage(_pages[pageNumber], _pageSize, new Rect(_pageSize), new Rect(_pageSize)); } }这个类的职责非常单一PageCount告诉打印系统有几页GetPage(pageNumber)返回第 N 页的DocumentPage。其中DocumentPage的构造函数四个参数分别是要打印的Visual、页面尺寸、内容区域、出血区域。实际使用时前两个最重要后两个给相同的Rect(_pageSize)就行。调用端变成PrintDialog dialog new PrintDialog(); if (dialog.ShowDialog() true) { // 拿到每一页的 Visual已经是按业务数据拆分好的 var pages BuildPagesFromData(businessData); var paginator new PrintAblePaginator(pages, new Size(dialog.PrintableAreaWidth, dialog.PrintableAreaHeight)); dialog.PrintDocument(paginator, 多页打印任务); }注意PrintableAreaWidth和PrintableAreaHeight是从PrintDialog里读的它已经扣掉了打印机不可打印的边距比你自己用纸张尺寸减去边距更准。这个细节很容易被忽略我见过有人硬编码 A4 尺寸结果不同打印机打印出来的版式漂移严重。3. 把业务数据切成页拆分 Visual 才是整个工程的重头戏3.1 一套数据多页展示的分页策略前面那个分页器只是被动的容器真正决定一页放多少内容的逻辑在调用方。这套项目里采用的做法是把业务数据先渲染成一个超长的StackPanel然后在分页时按纸张高度把它的子元素逐段切进多个新的Visual。这个过程听起来像截屏切图但千万不能真的用RenderTargetBitmap去截图因为位图打印出来是模糊的。正确做法是创建新的容器把需要显示的子元素重新布局到指定尺寸的区域内保持矢量输出。项目里用的方法是给每个子元素设置Margin和VerticalAlignment再放进一个固定大小的Grid中。基本切页思路代码示意public ListVisual SplitToPages(FrameworkElement longContent, double pageHeight, double pageWidth) { var pages new ListVisual(); double currentY 0; // 把长内容里的子元素按 Y 坐标累加超过 pageHeight 就新开一页 while (currentY longContent.ActualHeight) { var pageRoot new Grid { Width pageWidth, Height pageHeight, Background Brushes.White }; // 把 longContent 的剩余部分通过平移画进 pageRoot var translate new TranslateTransform(-longContent.ActualWidth * 0, -currentY); var child new ContentPresenter { Content longContent, RenderTransform translate }; pageRoot.Children.Add(child); pageRoot.Measure(new Size(pageWidth, pageHeight)); pageRoot.Arrange(new Rect(0, 0, pageWidth, pageHeight)); pages.Add(pageRoot); currentY pageHeight; } return pages; }这段代码利用了 WPF 布局系统的特性ContentPresenter内部持有长内容通过RenderTransform整体向上平移相当于每页显示长内容的不同区段。Measure和Arrange一定要显式调用否则Visual没有完成布局打印会是空白。这是最容易被忽略的一步。3.2 分页器源码的结构拆解这套项目里的PrintAblePaginator不是只有上面那一个类文件它还连带处理了几件事页眉页脚的附加、页码标注、以及不同打印机的边距兼容。你可以看到里面有一个受保护的虚方法CreatePageVisual子类可以重写它来给每一页加总页码或自定义水印。默认实现里每一页的Visual被包在一个Border中设置了Padding用来模拟页边距。这里有个设计上的取舍页边距是放在切页时处理还是放在GetPage 时处理项目里选择的是切页时处理——SplitToPages传入的pageHeight已经是打印机的可打印高度而创建页面Visual时再用Padding收缩内容区域。这样做的优势是预览和打印共用同一份分页数据屏幕看到的页边距就是打印出来的页边距。如果你拿到源码重点看CreatePageVisual这个方法protected virtual Visual CreatePageVisual(FrameworkElement content, int pageIndex, int totalPages) { var border new Border { Padding new Thickness(20), Background Brushes.White, Child content }; // 把页码放到页脚 var footer new TextBlock { Text $第 {pageIndex 1} 页 / 共 {totalPages} 页, HorizontalAlignment HorizontalAlignment.Right, VerticalAlignment VerticalAlignment.Bottom, Margin new Thickness(0, 0, 20, 10) }; var grid new Grid(); grid.Children.Add(border); grid.Children.Add(footer); return grid; }注意Padding new Thickness(20)这个值不是随便定的。它对应的是屏幕上预览时的页边距效果同时也直接影响打印出来的内容位置。如果你要改边距记得预览和打印两边都是走的同一个方法改一处就够了。3.3 数据驱动的动态表格怎么不破版动态表格是分页最容易翻车的场景列数多了超宽、行数多了超页。这份资源里给的方案是提前做两件事先测量再分页而不是先分页再测量。代码里有一个关键步骤是调用content.Measure(availableSize)拿到实际高度后再决定是否换页。对表格来说每一行的高度要提前算好累计高度超过剩余空间就触发新页。这里我用过的经验是给表格的外层ItemsControl设置固定宽度但不要设置高度让高度自然增长分页时读取ActualHeight作为总计高度。如果你的数据行数很多上万行就不能把所有行一次性挂到一个 Visual 里再切页否则Measure会卡顿。项目里虽然没做虚拟化但对几千行的报表场景是够用的。如果你的数据量更大建议分批渲染每 100 行生成一个Visual然后用多个Visual拼接分页。4. 打印预览实现让屏幕上看到的和纸上的一致4.1 用 DocumentPaginator 驱动预览控件预览控件不好做因为你要解决的不只是显示还有缩放翻页和实际打印结果一致。这套项目里封装了一个PrintPreviewControl : Control内部核心是一个ScrollViewer加一个ItemsControl。数据源直接绑定DocumentPaginator每次翻页时通过paginator.GetPage(index)取当前页的Visual交给ItemsControl展示。关键代码大概长这样public class PrintPreviewControl : Control { private DocumentPaginator _paginator; private int _currentPageIndex; public void LoadPaginator(DocumentPaginator paginator) { _paginator paginator; _currentPageIndex 0; RenderCurrentPage(); } private void RenderCurrentPage() { if (_paginator null || _currentPageIndex _paginator.PageCount) return; var page _paginator.GetPage(_currentPageIndex); ContentPresenter presenter new ContentPresenter { Content page.Visual }; // 按预览缩放比例包裹 var scale new ScaleTransform(PreviewScale, PreviewScale); presenter.RenderTransform scale; Content presenter; } public void NextPage() { if (_currentPageIndex _paginator.PageCount - 1) { _currentPageIndex; RenderCurrentPage(); } } public void PrevPage() { if (_currentPageIndex 0) { _currentPageIndex--; RenderCurrentPage(); } } }这里最值得学习的一点预览不是重新画一份内容而是复用paginator.GetPage()返回的同一个Visual。你预览时看到的就是打印时会输出的那个对象不存在预览和打印不一致的问题。4.2 缩放与滚动处理高分屏 DPI 的基础逻辑预览控件里常见的坑是屏幕 DPI 不是 96。如果你的电脑缩放比例是 125% 或 150%直接用ScaleTransform按 1.0 缩放页面看起来会比实际偏大或偏小。项目里用了两个参数来化解PreviewScale是用户手动调节的缩放倍率默认值按VisualTreeHelper.GetDpi(this).DpiScaleX校正初始值。这样在 150% 缩放的屏幕上打开预览初始显示比例是 1 / 1.5也就是实际大小的 66.7%保证一屏内能看到完整页面。double dpiScale VisualTreeHelper.GetDpi(this).DpiScaleX; PreviewScale 1.0 / dpiScale;这段代码的意图很明确以 96 DPI 为基准把缩放因子初始化为物理像素和 DIP 的比值。如果你的显示器是 1920 像素宽、150% 缩放实际可用逻辑宽度是 1280 DIP打印页面在屏幕上的像素宽度要乘以 1.5 才是真实观感所以要除以DpiScaleX才能让页面在屏幕上显示为逻辑尺寸。这会直接影响用户对预览真实性的信任感。4.3 预览控件集成到 MVVM 的方式这套项目是支持 MVVM 的预览控件没有直接持有视图模型而是通过依赖属性Paginator对外暴露public static readonly DependencyProperty PaginatorProperty DependencyProperty.Register(nameof(Paginator), typeof(DocumentPaginator), typeof(PrintPreviewControl), new PropertyMetadata(null, OnPaginatorChanged)); private static void OnPaginatorChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ((PrintPreviewControl)d).LoadPaginator(e.NewValue as DocumentPaginator); }绑定方式就是标准的ctrl:PrintPreviewControl Paginator{Binding PrintPaginator} /在视图模型里打印和预览用的是同一个DocumentPaginator实例打印时dialog.PrintDocument(paginator, ...)预览时把它绑定给控件。这个设计让打印预览从打印功能中解耦出来——你想在打印前让用户勾选参数再刷新预览只需要替换绑定源。5. 避坑记录WPF 打印与图标输出的五个典型翻车现场5.1 图标打印出来是模糊的马赛克现象屏幕上清晰的 PNG 图标打印出来边缘锯齿明显像低分辨率位图被强行放大。原因图标源文件本身是 32x32 或 64x64 像素屏幕显示有系统缩放看着还行但打印机的渲染 DPI 通常在 300 到 600 DPI把位图从屏幕分辨率放大到打印分辨率像素不够就直接糊了。解决优先用矢量格式DrawingImage、XAML Path代替 PNG。如果只能用位图选择分辨率不低于 256x256 的源文件并在打印前手动设置BitmapImage.DecodePixelWidth让它以目标尺寸解码而不是原尺寸解码后再拉伸。另外一个可操作的做法是BitmapImage logo new BitmapImage(); logo.BeginInit(); logo.UriSource new Uri(pack://application:,,,/Assets/logo.png); logo.DecodePixelWidth 600; // 按打印需要的高分辨率解码 logo.EndInit();这个DecodePixelWidth很关键WPF 默认按原图尺寸解码如果原图 64x64直接显示没问题但打印放大会模糊。提前指定解码宽度为打印尺寸的两倍可以缓解锯齿。注意别把这个值设太大否则内存占用膨胀。5.2 打印预览和实际打印结果对不上现象预览做了页面缩放、翻页看起来都正常打印出来的第一页和第二页之间的内容发生偏移甚至部分内容被裁掉。原因预览控件和打印用的Paginator暴露了同一个PageSize但打印机的PrintableAreaHeight和屏幕预览用的Height不一样。如果你在预览时把页面固定为某个值比如new Size(793, 1122)A4 的 96 DPI 等数值而打印机实际可打印高度是 1100就到了打印环节PageSize被替换分页逻辑重新执行内容位置自然变了。解决分页必须基于最终输出设备的尺寸而不是一个静态常量。正确做法是在打开打印对话框之后才用dialog.PrintableAreaHeight和dialog.PrintableAreaWidth构建Paginator。预览时的页面尺寸同样用这两个值——先调用一次PrintDialog但不打印只读取尺寸。5.3 内容超过一页时第二页是空白现象数据明显超过一页切页逻辑也执行了但第二页打出来是白纸。原因切页用的longContent.ActualHeight是 0。这个组件没有被加载到可视树上Measure没有触发所有值都是默认的0或NaN。分页循环进不去自然只有一页。解决在调用SplitToPages之前强制走一遍布局longContent.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); longContent.Arrange(new Rect(0, 0, longContent.DesiredSize.Width, longContent.DesiredSize.Height));这段代码必须放在ActualHeight读取之前。如果longContent是动态生成后直接打印记得先把它放进一个Window或者Popup让 WPF 完成布局或者走上面这两步手动布局。这是我做打印功能时血泪经验里排名前三的坑。5.4 PrintVisual 打印出来的界面是纯白现象调用PrintVisual打印自定义 UserControl输出到纸上是空白的。原因这个 UserControl 从未被加到任何窗口的可视树中RenderSize为 0渲染系统不知道要画什么。解决动手打印前先给控件一个明确尺寸并执行一次完整的测量与排列var uc new MyUserControl(); uc.Width 800; uc.Height 600; uc.Measure(new Size(800, 600)); uc.Arrange(new Rect(0, 0, 800, 600));然后是打印dialog.PrintVisual(uc, 单页打印);记住Measure和Arrange是 WPF 渲染的前提不做这两步直接扔进打印管线跟把画布悬挂在墙上但不放画是一样的意思。5.5 打印时行间距或列宽与预览偏差几个像素现象预览里表格排得整整齐齐打印出来行高多了 2 像素、列宽窄了几个单位。原因屏幕 A 的字体渲染和打印机驱动 B 的字体渲染不完全一致。同样的FontSize12在屏幕上可能高度是 15.9 像素打印机计算出来是 16.3 像素差距不大但累积到底部就可能导致最后一行的位置偏移。另一个原因是行内的Margin或Padding在不同 DPI 下被四舍五入。解决所有尺寸不要用绝对值尽量用相对单位或者确保关键尺寸是偶数。更有效的做法是打印前把容器宽度固定为一个较大的值比如 1024并额外多留 10 像素的底部出血。表格列宽用*比例而非固定像素。6. 进阶技巧给分页器加页眉水印与全局页码把页码加在每一页的底部只是入门我见过不少业务场景需要在每一页重复打印表头和公司水印。这套项目预留的CreatePageVisual就是干这个的你可以在自己的分页器里重写它。比如要加页眉 Logo 和表头文字protected override Visual CreatePageVisual(FrameworkElement content, int pageIndex, int totalPages) { var grid new Grid(); // 第一行页眉区域 var header new DockPanel { Height 40, VerticalAlignment VerticalAlignment.Top }; var logo new Image { Source GetLogoSource(), Width 28, Height 28, HorizontalAlignment HorizontalAlignment.Left }; var title new TextBlock { Text 销售统计报表, FontSize 16, FontWeight FontWeights.Bold, HorizontalAlignment HorizontalAlignment.Center, VerticalAlignment VerticalAlignment.Center }; header.Children.Add(logo); header.Children.Add(title); var body new ContentPresenter { Content content, Margin new Thickness(0, 45, 0, 0) }; var footer GetFooter(pageIndex, totalPages); grid.Children.Add(header); grid.Children.Add(body); grid.Children.Add(footer); return grid; }这里有一个我常用的验证方法把同一份分页器同时绑定到预览控件和打印对话框然后在预览控件里连续翻几页确认每一页的表头是否重复出现、页码是否递增。如果表头只出现在第一页多半是你在分页时才生成页眉而不是在CreatePageVisual层生成。另一个容易顺手做错的点是页眉的高度要算进可打印区域的尺寸里否则页眉和第一行内容会重叠。若PageSize.Height是 1122页眉 40 像素正文区域就需要保留为 45 球边距后再开始排内容——页眉本身占用面积但不可打印区域并不因此变大。验证打印结果最稳妥的办法不是看预览截图而是用PrintDialog输出到 XPS 文件再打开 XPS 检查页面边界。你可以调用PrintQueue添加一个XpsDocumentWriter把Paginator写入本地 XPS 文件这相当于给打印结果留了一个后悔药using (var xpsDoc System.Windows.Xps.Packaging.XpsDocument.CreateXpsDocumentWriter()) { xpsDoc.Write(paginator); }这个操作不会消耗纸张还能精确检查每一页的边距、页眉、页码位置。从那以后我每次写完打印分页逻辑都强制走一遍 XPS 输出再上真机翻车率低了不少。希望帮到你。本文还有配套的精品资源点击获取