news 2026/10/12 2:55:48

WPF打印与预览实战:分页器、图标DPI与预览控件封装

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WPF打印与预览实战:分页器、图标DPI与预览控件封装

简介:一份面向WPF开发人员的打印与预览功能示例,重点解决在应用中打印图标、图像及自定义UI内容的需求。压缩包内含43个文件,以C#源码、XAML界面、项目配置及可运行exe为主,附带pdb调试符号与资源文件,整体约78KB,便于直接打开工程查看实现细节。已有310人学习下载。示例工程涵盖PrintDialog调用、FixedDocument构造、Visual视觉打印及XPS输出等关键环节,通过具体代码演示如何将图标、位图乃至地图内容准确呈现到预览与纸质输出中,同时兼顾了打印分辨率、颜色透明度和多页面组织等实际问题。适合正在学习WPF打印机制或需要快速为项目接入打印功能的初中级开发人员。

1. WPF 打印与预览:先分清你要的是"出图"还是"出纸"

做 WPF 客户端的人迟早会遇到打印需求,而且多半是从一个看似简单的问题开始的:对方给了一份模板,要求把业务数据填进去,既能屏幕预览,又能一点打印输出——顺便还要把公司 Logo 图标一并打出来。如果你直接拿PrintDialog改两下就交差,十有八九会翻车,因为 WPF 打印体系里的"打印"和"预览"是两个层次的东西:底层是PrintDialog+Visual的快速通道,适合整块界面原样输出;而真正可控的分页、页边距、逐页预览,得靠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 List<Visual> _pages = new List<Visual>(); private Size _pageSize; public PrintAblePaginator(List<Visual> 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 List<Visual> SplitToPages(FrameworkElement longContent, double pageHeight, double pageWidth) { var pages = new List<Visual>(); 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 的字体渲染不完全一致。同样的FontSize=12,在屏幕上可能高度是 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 输出再上真机,翻车率低了不少。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 2:54:42

驱动之路:Linux内核驱动开发学习路线与调试实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 2:54:15

树莓派OS任务栏修复:lxpanel 0.10.4稳定升级指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 2:53:09

游戏对象与资源管理:从内存泄漏到工程化实践

1. 从一次内存泄漏事故说起&#xff1a;游戏对象与资源管理到底在管什么几年前我参与过一个中型动作游戏的性能优化&#xff0c;项目上线前两周&#xff0c;测试同学反馈&#xff1a;连续游玩四十分钟后帧率从稳定的60帧掉到22帧&#xff0c;重启后恢复正常。我们一开始怀疑是渲…

作者头像 李华
网站建设 2026/10/12 2:52:59

FOC实战入门:从电流采样到电流环闭环的工程化调试路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 2:52:45

ERP数据库文档实战指南:从表结构到SQL查询与数据修改避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 2:48:34

Linux uname命令全解析:从内核信息到架构判断的实战指南

拿到一台新服务器或者接手一套陌生环境时&#xff0c;我习惯先跑一条uname -a。原因很简单&#xff1a;这条命令不会因为缺少某个软件包、没有图形界面、连不上外网而罢工&#xff0c;只要内核起来了&#xff0c;它就能给你吐出一串关键信息。很多朋友喜欢一上来就cat /etc/os-…

作者头像 李华