资讯详情

WPF打印预览与FixedDocument分页:从可视树构造到DPI清晰度全解析

📅 2026/10/23 10:20:15 | 华诺云谱 👁 阅读
WPF打印预览与FixedDocument分页:从可视树构造到DPI清晰度全解析
简介一套面向WPF开发者的打印与预览示例工程用于解决WPF应用中调用系统打印对话框、实现打印预览以及输出图标和地图图像等需求。压缩包共43个文件包含9个C#源文件、2个XAML界面文件、可执行程序及调试符号文件项目分为两个示例工程整体体积仅78KB。已有310人学习下载适合初中级开发者参考。示例涵盖了打印对话框、固定文档、视觉打印与XPS文档生成等核心接口演示了将界面元素转换为视觉对象、构造打印页面、设置打印参数并显示预览的完整流程同时针对位图图标和矢量地图在打印时的清晰度、透明度和缩放问题提供了处理建议便于快速集成到实际项目中。资源同时包含解决方案与工程文件可直接打开编译运行方便对照学习。1. WPF打印及预览为什么不能直接依赖 PrintDialog很多做 WPF 桌面端的开发者第一次接触打印功能时第一反应都是拖一个 PrintDialog 出来调一下 PrintVisual 就收工。这个方案在只打一张 A4 文本时确实够用但只要需求里出现「预览」「图标」「分页」「不同纸张」这几个词PrintDialog 就撑不住了因为它根本没有自带预览窗口打印效果只能靠猜图标在高 DPI 屏上还容易打出一团糊。真正落地的做法是先把打印内容构造成可视树再用 FixedDocument 做预览和分页控制最后才交给打印队列。这套思路在小型单据打印、工单标签、设备铭牌这类场景里非常实用做完一次之后后续再遇到打印需求基本就是换模板的事。这份资源的价值在于它把「打印」和「预览」串成了一条完整链路内容构造、分页、预览、输出、图标清晰度处理都有对应的实现而不是只给你一个孤零零的 PrintDialog 示例。本文会按「内容怎么造 → 预览怎么出 → 图标怎么不糊 → 坑在哪 → 怎么验证」这条路线把代码和参数拆开讲。适合正在做 WPF 项目但被打印需求卡住的开发者也适合想系统补齐打印这块短板的桌面端工程师。2. 打印内容构造用 FlowDocument 还是自定义可视树WPF 打印的第一步不是调打印对话框而是先把「要打什么」组织成可视化对象。常见做法有两种一种是 FlowDocument适合流式文本、自动分页、内容长度不固定的场景另一种是自定义可视树适合精确控制坐标、打印图标、票据排版这类需要像素级对齐的场景。这个资源采用的是第二种思路因为它同时覆盖了文本和图标打印对布局的控制力要求更高。2.1 为什么自定义可视树更合适FlowDocument 的优势是自动换行和自动分页但代价是排版引擎介入太多打印坐标不好控制。假如要在单据左上角打一个 48×48 的图标右侧接一段说明文字下面再固定位置打条码FlowDocument 做起来会很别扭。自定义可视树则完全由代码构建 DrawingVisual 或 ContainerVisual坐标、尺寸、缩放全部自己说了算打印出来的效果和预览完全一致。这个资源里的核心做法是构建一个容器可视对象把所有打印元素挂上去再交给分页逻辑去切割。具体来说就是先定义一个打印内容类里面持有需要绘制的文本和图标然后通过自定义的 Draw 方法把内容渲染到 DrawingContext 上。public class PrintContent { public string Title { get; set; } public string Description { get; set; } public ImageSource Icon { get; set; } public double IconWidth { get; set; } 48; public double IconHeight { get; set; } 48; public void Draw(DrawingContext dc, double offsetX, double offsetY) { // 绘制标题 var titleFont new Typeface(new FontFamily(Microsoft YaHei), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); var titleBrush new SolidColorBrush(Colors.Black); dc.DrawText(new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, titleFont, 16, titleBrush, VisualTreeHelper.GetDpi(DummyVisual).PixelsPerDip), new Point(offsetX, offsetY)); // 绘制图标 if (Icon ! null) { dc.DrawImage(Icon, new Rect(offsetX 200, offsetY, IconWidth, IconHeight)); } } }这段代码定义了打印内容的数据结构和绘制入口。Draw 方法接收 DrawingContext 和偏移量偏移量用于分页时定位内容在当前页的位置。FormattedText 是 WPF 打印文本的标准方式这里传入的 PixelsPerDip 参数直接影响文字清晰度如果不传或传错打印出来的文字在 96 DPI 以下的设备上会明显发虚。2.2 分页是怎么切出来的内容构造完之后接着就要考虑分页。WPF 里分页的机制是 DocumentPaginator它接收一个可视对象按页面尺寸把可视树裁剪成多页。这个资源实现了一个自定义的 Paginator核心逻辑是计算可视内容的总高度然后按 PageHeight 一页一页往下切。public class CustomPaginator : DocumentPaginator { private readonly Visual _sourceVisual; private readonly Size _pageSize; public CustomPaginator(Visual source, Size pageSize) { _sourceVisual source; _pageSize pageSize; } public override bool IsPageCountValid true; public override int PageCount (int)Math.Ceiling(_sourceVisual.ContentBounds.Height / _pageSize.Height); public override Size PageSize { get _pageSize; set throw new NotSupportedException(); } public override DocumentPage GetPage(int pageNumber) { var visual new ContainerVisual(); visual.Children.Add(_sourceVisual); visual.Offset new Vector(0, -pageNumber * _pageSize.Height); return new DocumentPage(visual, _pageSize, new Rect(_pageSize), new Rect(_pageSize)); } }这里的 PageCount 计算是分页的关键。_sourceVisual.ContentBounds.Height 拿到的是内容实际渲染高度除以页高向上取整得到总页数。GetPage 里通过设置 visual.Offset 的 Y 轴偏移把内容往上挪动对应页数的距离这样每一页拿到的可视对象都只是内容的一段。这个做法的好处是每页都是独立的 ContainerVisual后续预览和打印都能直接复用。3. 打印预览实现DocumentViewer 是靠谱的落地方案预览功能是这个资源里最实用的部分。PrintDialog 不提供预览所以必须自己搭一个预览窗口。方案其实只有两条路一条是把 FixedDocument 塞进 DocumentViewer另一条是把预览内容渲染成图片塞进 Image 控件。前者是正路支持缩放、翻页、搜索和实际打印走的是同一条渲染管线后者适合快速看个大概但放大后全是马赛克不能作为正式方案。3.1 FixedDocument 的组装过程要做预览第一步是把前面生成的分页内容组装成 FixedDocument。每个 DocumentPage 对应一个 FixedPageFixedPage 再挂到 PageContent 里最后加进 FixedDocument。这个资源里有一个 BuildFixedDocument 方法专门干这件事。public FixedDocument BuildFixedDocument(Visual content, Size pageSize) { var paginator new CustomPaginator(content, pageSize); var fixedDoc new FixedDocument(); for (int i 0; i paginator.PageCount; i) { var page paginator.GetPage(i); var fixedPage new FixedPage(); fixedPage.Width pageSize.Width; fixedPage.Height pageSize.Height; var child page.Visual as ContainerVisual; if (child ! null) { var host new FixedPage(); // 把可视对象挂到 FixedPage 上 var visualHost new VisualHost(child); fixedPage.Children.Add(visualHost); } var pageContent new PageContent(); ((IAddChild)pageContent).AddChild(fixedPage); fixedDoc.Pages.Add(pageContent); } return fixedDoc; }这里有个容易翻车的地方FixedPage 的 Width 和 Height 必须和 Paginator 的 PageSize 保持一致否则预览和打印的页面比例会错位。VisualHost 是资源里定义的一个小类作用是让非 UIElement 的可视对象也能成为 FixedPage 的子元素。不经过这层包装直接 Add 的话运行时会抛 InvalidOperationException因为 FixedPage 只接受 UIElement 类型的子节点。3.2 预览窗口的布局方式FixedDocument 组装好之后预览窗口就很简单了。直接放一个 DocumentViewer把 FixedDocument 赋给它的 Document 属性DocumentViewer 会自动提供缩放滑块、翻页按钮和文本搜索。这个资源里的预览窗口用的是经典两栏布局左侧是 DocumentViewer右侧是打印参数面板可以在预览前调整纸张大小和边距。Window x:ClassPrintPreviewDemo.PreviewWindow Title打印预览 Width900 Height700 Grid Grid.ColumnDefinitions ColumnDefinition Width*/ ColumnDefinition Width260/ /Grid.ColumnDefinitions DocumentViewer x:NamePreviewViewer Grid.Column0/ StackPanel Grid.Column1 Margin12 TextBlock Text纸张大小/ ComboBox x:NamePaperSizeBox SelectionChangedPaperSizeBox_SelectionChanged ComboBoxItem ContentA4/ ComboBoxItem ContentA5/ ComboBoxItem Content自定义/ /ComboBox TextBlock Text页边距(毫米) Margin0,12,0,0/ TextBox x:NameMarginBox Text10/ Button Content刷新预览 ClickRefreshPreview_Click Margin0,12,0,0/ Button Content直接打印 ClickPrint_Click Margin0,8,0,0/ /StackPanel /Grid /Window这个窗口布局本身不是重点重点是预览和打印共用同一个 FixedDocument 实例。很多人的误区是预览用一套分页逻辑打印的时候又重新创建一份内容导致预览和实际打印结果不一致。资源里明确的做法是先 BuildFixedDocument预览时把它交给 DocumentViewer打印时从同一个 FixedDocument 里取 DocumentPaginator 交给 PrintDialog这样两边永远是一致的。4. 图标打印清晰度DPI 缩放是核心问题图标打印是很多 WPF 打印方案里被忽略的环节。屏幕上显示好好的图标打印出来总是糊。这个问题的根源不是打印机的分辨率不够而是 WPF 的坐标系统是 DPI 无关的默认按 96 DPI 逻辑坐标计算而打印机通常是 300 DPI 甚至 600 DPI 的物理分辨率。如果不在绘制时做 DPI 适配图标在物理设备上会被强行拉伸边缘锯齿非常明显。这个资源专门处理了这个问题做法是让图标参与 DrawingVisual 渲染时携带正确的 PixelsPerDip同时对位图做缩放。4.1 图标渲染时的 PixelsPerDip 处理WPF 从 4.6 开始支持 PixelsPerDip 属性但这个属性只在 FormattedText 上有DrawingContext.DrawImage 并不直接支持。所以图标的清晰度需要手动控制缩放。资源里的做法是判断图标的原始像素尺寸再结合目标 DPI 计算实际绘制尺寸。public void DrawIcon(DrawingContext dc, ImageSource icon, double x, double y, double targetDip) { // 获取图标原始像素尺寸 double pixelWidth icon.Width; double pixelHeight icon.Height; // 目标 DPI 下的逻辑尺寸 double displayWidth pixelWidth / (targetDip / 96.0); double displayHeight pixelHeight / (targetDip / 96.0); // 绘制时设置 UseLayoutRounding 避免边缘锯齿 dc.DrawImage(icon, new Rect(x, y, displayWidth, displayHeight)); }这里的核心逻辑是把物理像素换算成逻辑像素。比如一张 96×96 的图标在 300 DPI 的打印环境下目标 DIP 约为 3.125300/96那么逻辑尺寸就变成 96 / 3.125 ≈ 30.7 像素。如果直接按 96 逻辑像素去画打印出来实际会占到 300 物理像素而图标本身只有 96 物理像素结果必然是糊的。换算之后打印硬件会用 96 物理像素点阵去表现 30.7 逻辑尺寸的区域这样才能保证每一个物理像素都有对应的图像数据。4.2 圆角图标和透明背景的处理坑除了 DPI 换算图标打印还有一个常见问题是透明背景和抗锯齿。PNG 图标通常带 Alpha 通道打印到白纸上没问题但如果要打印在带底色或者预印表格的纸上透明区域会透出背景色看起来像是图标周围有一圈脏色。这个资源的处理方式是在打印前对图标做一次背景合成把透明区域填充成白色再做 DPI 缩放。public static BitmapSource FlattenIcon(BitmapSource source, Color backgroundColor) { var drawingVisual new DrawingVisual(); using (var dc drawingVisual.RenderOpen()) { var brush new SolidColorBrush(backgroundColor); dc.DrawRectangle(brush, null, new Rect(0, 0, source.Width, source.Height)); dc.DrawImage(source, new Rect(0, 0, source.Width, source.Height)); } var bitmap new RenderTargetBitmap( (int)source.Width, (int)source.Height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(drawingVisual); return bitmap; }这个 FlattenIcon 方法接收一个带透明通道的 BitmapSource先画一个白色背景再把图标叠上去最后通过 RenderTargetBitmap 合成一张不透明的位图。之后这张位图再参与 DPI 缩放和打印就不会出现背景色污染的问题。要注意的是 RenderTargetBitmap 的 DPI 参数这里设成了 96因为后续打印时还要做一次目标 DPI 的换算如果这里就设成 300等于提前做了物理缩放后面再算就重了。5. 打印避坑五个高频问题与解决记录打印这块的坑和网络请求不一样出错不会立刻崩多数是「看着不对但不知道怎么调」。这一章整理了这个资源里提到的和我在实际验证中遇到的高频问题按现象、原因、解决三条线写清楚方便这套方案用在自己的项目里时快速排查。5.1 预览正常打印出来文字发虚现象DocumentViewer 里看预览效果很好文字边缘清晰但走打印机打出来文字明显发虚甚至有些笔画缺色。原因FormattedText 构造时没有传 PixelsPerDip 参数或者传的一直是设备无关的 96。预览时屏幕 DPI 一般是 96 或系统缩放后的值文字渲染看不出问题打印机是 300 DPI 物理分辨率文字栅格化时像素点不够就会虚。解决在创建 FormattedText 之前先通过 VisualTreeHelper.GetDpi 拿到目标可视对象的 DPI 信息然后把 PixelsPerDip 传进 FormattedText 的构造参数。打印时这个值应该由打印机分辨率换算得到建议在打印参数设置里显式暴露出来。注意系统缩放比例是 125% 或 150% 时屏幕上的 PixelsPerDip 值是 1.25 或 1.5这个值不能直接用于打印打印要按打印机 DPI 除以 96 计算常见的是 300/96 ≈ 3.125、600/96 6.25。5.2 图标打印出来尺寸不对比屏上预览小一大截现象屏幕上图标显示 48×48 逻辑像素预览窗口里看着也正常但打出来实际物理尺寸只有 1 厘米左右和设计的 48 像素完全对不上。原因打印坐标用的是 WPF 设备无关单位而打印机解析坐标时按物理 DPI 换算。48 个 WPF 单位在 96 DPI 逻辑下等于 0.5 英寸但打印时如果没有缩放硬件直接按 48 个物理像素点来渲染于是实际尺寸变成了 48/300 英寸。解决绘制图标前按打印目标 DPI 做缩放换算也就是前面提到的 PixelsPerDip 逻辑。换算公式是逻辑尺寸 设计尺寸 / (目标DPI / 96)。同时检查打印机的 PageMediaSize 是否和预览时用的 PageSize 一致防止打印机的物理页面尺寸覆盖了程序里设置的尺寸。5.3 多页文档打印时第二页以后内容整体偏移现象第一页打出来完全正常第二页开始内容往下偏页数越多偏移越严重。原因GetPage 里设置 Visual 偏移时用的是 _pageSize.Height但没考虑不同页的实际可用高度。如果之前的页面存在页边距或页脚占位内容的起绘点应该从每页的边距起点开始而不是简单等于页高乘以页号。另一种原因是打印机的不可打印区域占了页面上缘程序没感知到。解决分页时把页边距纳入坐标计算。具体做法是内容总高度减去页边距得到实际可用高度GetPage 里偏移量用「页号 × 可用高度 - 页边距」来算。打印机方面通过 PrintQueue.GetPrintCapabilities 读取 HardwareMargin 属性把不可打印边距叠加到自定义页边距里。5.4 预览窗口打开时闪一下黑屏然后才显示内容现象点击打印预览按钮DocumentViewer 窗口先黑一会儿再慢慢显示出内容。内容复杂时黑屏时间能到两三秒。原因FixedDocument 的构建和渲染都在 UI 线程同步进行分页计算量一大UI 线程被阻塞窗口就没法及时绘制。解决把 BuildFixedDocument 放到后台线程执行完成后通过 Dispatcher 切换到 UI 线程更新 DocumentViewer。由于 FixedDocument 本身依赖 WPF 的 DispatcherObject不能在后台线程直接修改它的属性但可以先在后台线程构建所有 DocumentPage 的数据源再到 UI 线程组装 FixedDocument。这个资源里推荐的做法是后台用 VisualTarget 渲染分页内容到 RenderTargetBitmap这样分页计算完全脱离 UI 线程窗口不会卡顿。5.5 装了多台打印机时点击打印没有反应现象程序里用 PrintDialog 弹出打印选择窗口后选了某台打印机点击确定后没有任务进入打印队列程序也不报错。原因PrintDialog.PrintTicket 和 PrintQueue 的绑定关系在会话切换或打印机重新枚举后失效。尤其是网络打印机掉线再恢复后PrintQueue 对象持有的是旧句柄调用 PrintDocument 时静默失败。解决每次点击打印按钮时重新获取 PrintQueue 实例不要缓存。代码里先通过 PrintDialog 拿用户选择的打印机名再重新枚举 LocalPrintServer 下的 PrintQueue 来获取新实例。另外打印前建议先调用 PrintQueue.GetPrintCapabilities 检查 printer.IsValid 和 printer.IsOffline提前感知打印机状态避免点了没反应。提示网络打印机还有一个隐藏问题——PrintTicket 里的分辨率设置如果超过打印机支持范围打印任务会卡在队列里不报错。设置打印机分辨率时先读取 GetPrintCapabilities 的 DpiX 和 DpiY 范围再赋值。6. 用 FixedDocument 渲染成 PNG打印前的自检手段最后一章给一个实用技巧把 FixedDocument 的每一页渲染成 PNG 图片用来做打印前的自动自检。这个方法在正式模块里不一定需要但调试和验收阶段几乎是神兵利器。先说一下为什么需要它。打印是一个不可逆操作纸张和墨粉都花钱而且每次跑打印机确认效果效率太低。如果把 FixedDocument 渲染成图片就能在不上打印机的情况下检查排版、图标清晰度、分页是否合理还能把图直接发给业务方确认减少反复沟通的成本。FixedDocument 转 PNG 的思路不复杂遍历 FixedDocument 的 Pages对每个 PageContent 调用 GetPage 拿到 DocumentPage再把 DocumentPage.Visual 渲染到 RenderTargetBitmap 上最后编码成 PNG。为了让检查效果更接近真实打印渲染图片时可以把 DPI 设为打印机的目标 DPI比如 300 或 600这样能在图片上看到实际打印时会呈现的字形和图标细节。public void ExportToPng(FixedDocument doc, string outputFolder, double dpi 300) { for (int i 0; i doc.Pages.Count; i) { var pageContent doc.Pages[i]; var page pageContent.GetPage(0); // 页面尺寸(按目标 DPI 换算成像素) double widthPx page.Size.Width * dpi / 96; double heightPx page.Size.Height * dpi / 96; var bitmap new RenderTargetBitmap( (int)Math.Ceiling(widthPx), (int)Math.Ceiling(heightPx), dpi, dpi, PixelFormats.Pbgra32); bitmap.Render(page.Visual); var encoder new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); string filePath Path.Combine(outputFolder, $page_{i 1}.png); using (var fs File.Create(filePath)) { encoder.Save(fs); } } }这个方法的参数说明dpi 参数决定输出图片的物理分辨率300 DPI 下 A4 纸的 PNG 宽约 2480 像素、高约 3508 像素足以检查大部分排版细节。RenderTargetBitmap 的 dpi 参数这里同时承担了位图像素密度和绘制缩放两个作用所以页面尺寸必须按 dpi/96 先换成像素再传入否则图片会因绘制区域不足而截断。实际使用中我一般会把导出的 PNG 按页一张张贴到一个临时目录然后用图片查看器快速翻一遍重点检查三件事每一页是否都有内容没有因为分页错误产生空白页图标在 300 DPI 下是否清晰边缘有没有锯齿文字是否正常有没有因为字体缺失变成方框。如果有问题直接在代码里改布局参数再导出对比比每次跑打印机试错快很多。后来再做打印功能时我都会强制把「导出 PNG 做前置检查」走一遍再放给用户用这已经成了我自己的固定习惯。打印这类功能不怕逻辑复杂就怕把问题留给用户去发现前置多花一分钟后面能省掉一整天的返工希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑