ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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 对应一个 FixedPage,FixedPage 再挂到 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:Class="PrintPreviewDemo.PreviewWindow" Title="打印预览" Width="900" Height="700"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="260"/> </Grid.ColumnDefinitions> <DocumentViewer x:Name="PreviewViewer" Grid.Column="0"/> <StackPanel Grid.Column="1" Margin="12"> <TextBlock Text="纸张大小"/> <ComboBox x:Name="PaperSizeBox" SelectionChanged="PaperSizeBox_SelectionChanged"> <ComboBoxItem Content="A4"/> <ComboBoxItem Content="A5"/> <ComboBoxItem Content="自定义"/> </ComboBox> <TextBlock Text="页边距(毫米)" Margin="0,12,0,0"/> <TextBox x:Name="MarginBox" Text="10"/> <Button Content="刷新预览" Click="RefreshPreview_Click" Margin="0,12,0,0"/> <Button Content="直接打印" Click="Print_Click" Margin="0,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.125(300/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 做前置检查」走一遍再放给用户用,这已经成了我自己的固定习惯。打印这类功能不怕逻辑复杂,就怕把问题留给用户去发现,前置多花一分钟,后面能省掉一整天的返工,希望帮到你。

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

RELATED READING

延伸阅读

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