WPF打印图标发虚黑块?预览与打印双路径的清晰度控制方案
简介这是一份面向WPF开发者的打印与预览示例工程围绕PrintDialog、FixedDocument、Visual Print及XPS文档输出等核心机制演示如何在应用中直接打印文本、图标乃至地图类图像并支持打印前预览。资源压缩包共43个文件主代码以C#源文件9个cs和XAML界面为主另有sln/csproj工程文件、exe/dll可运行程序与pdb调试文件、png图标及少量配置文件整体约78KB结构紧凑适合初中级WPF开发者快速上手。工程附带完整的Visual Studio解决方案打开即可编译运行通过示例可理解打印机设置、多页FixedPage组织、UI元素转Visual打印等关键写法同时可参考作者对位图图标透明度、地图缩放与预览渲染一致性的处理思路。已有310人学习下载适合需要实现WPF文档打印、图文预览或报表输出功能的开发人员作为起步模板。1. WPF打印及预览可以打印图标为什么预览正常打印却翻车某公司内部工单系统做巡检单打印需求很朴素把设备信息打出来每行前面带一个小图标方便现场人员按图识别设备。负责的开发者第一版实现很乐观弹个 PrintDialog、调一下 PrintVisual就觉得打印功能完事了。结果一到真机测试就翻车预览窗口里图标边缘锐利、颜色鲜艳打印出来却发虚、偏色有一类图标甚至直接变成黑块。问题不在于打印代码本身而在于对 WPF 打印链路缺少整体认知——预览走的是屏幕渲染路径打印走的是独立的打印渲染路径两者在 DPI、缩放、透明通道处理上完全不同。这篇文章把「预览」和「打印」拆开设计用 XPS 作为中间数据载体统一两条路径图标打印则用 DrawingVisual 承载配合 RenderOptions 和 PrintTicket 参数控制清晰度。文章按「原理→实现→调参→避坑→进阶」推进适合正在做 WPF 打印功能、被 PrintVisual「够用就行」坑过、或者需要把图标这类小尺寸位图打清楚的人。下面代码都是从可运行项目里提炼的参数是实际调过的值照着抄能少走弯路。2. 打印链路拆解从 Visual 到打印机的三条路与图标渲染原理2.1 三条打印路线选型PrintVisual、PrintDocument 与 XPS 中间层WPF 里把内容送到打印机常见的有三条路线各有适用场景选错后面全是坑。第一条是最简单的PrintDialog.PrintVisual(visual, description)。它接受任何 Visual 对象测量、布局、渲染全交给 WPF 内部完成打完即止。适合单页、一次性输出比如打印一个简单的标签。问题在于它不给你任何分页控制权内容超出一页时会被硬生生截断而且打印质量不可控——字体和位图都按默认参数处理图标这类小图放大后特别容易糊。第二条是PrintDialog.PrintDocument(paginator, description)传入一个DocumentPaginator对象。分页逻辑、每页放什么、页边距都由你说了算这是多页打印的正路。代价是你需要自己实现一个分页器或者用FixedDocument提前布好页面。很多开发者觉得写分页器麻烦于是退回 PrintVisual结果打三页以上的内容就各种奇怪。第三条是我最推荐的路线用 XPS 作为中间层。思路是把 Visual 渲染成 XPS 文档这个文档既能喂给DocumentViewer做预览也能直接发往打印机。它的好处是预览和打印用的同一份数据预览所见即打印所得不会出现「预览正常打印偏移」这种经典翻车。选型建议很简单单页、内容固定、不打位图直接用 PrintVisual多页且需要精确控制布局用 DocumentPaginator既要有预览又要有打印就上 XPS。我实际做巡检单打印时选的就是第三条因为需求里明确要求「打之前先看一眼」而且图标这类位图必须经过可控的缩放通道。2.2 Visual 转打印数据DrawingVisual 为什么比 Image 更适合打印图标WPF 的打印输出本质上是把 Visual 树里的内容栅格化成打印分辨率下的位图再交给打印驱动。这个栅格化过程对矢量元素线条、文字很友好对位图元素就敏感了——位图的原始分辨率直接决定输出质量。打印图标时最常见的做法是在界面上放一个Image控件然后把整个Grid丢给打印。这能打但有两个隐患第一Image控件受布局容器影响尺寸可能被拉伸拉伸时如果没设BitmapScalingMode缩放算法用默认值打印出来边缘必然出现锯齿第二图标周围的透明区域会被当成真正的透明内容渲染在部分打印机驱动下变成黑色。我一般不用Image控件做打印内容而是用DrawingVisual直接在 DrawingContext 里画位图。原因很直接DrawingVisual是个轻量 Visual不参与布局系统不会被父容器拉伸或裁剪它的 RenderOpen 方法拿到的 DrawingContext 支持 DrawImage、PushClip、PushOpacity 等操作打印时完全可控。你可以在一个 DrawingVisual 里画背景、画图标、画文字最终作为一个整体输出到打印机页面上每个元素的位置都是算出来的而不是靠布局挤出来的。核心区别在于Image是 UI 元素受 Measure/Arrange 影响DrawingVisual是你自己的画布画什么位置就是什么位置。对打印这种需要精确坐标的场景后者明显更靠谱。// 用DrawingVisual承载图标避免Image控件被布局干扰 private DrawingVisual BuildIconVisual(ImageSource iconSource, Rect targetRect) { DrawingVisual visual new DrawingVisual(); using (DrawingContext context visual.RenderOpen()) { // DrawImage会把位图绘制到指定矩形缩放算法由外部RenderOptions控制 context.DrawImage(iconSource, targetRect); } return visual; }这段代码里targetRect是图标在打印页上的目标位置和尺寸单位是 DIP设备无关像素1/96 英寸。DrawImage接受任意ImageSource但位图缩放质量不会在这里设置需要在调用方用RenderOptions.SetBitmapScalingMode控制。如果 targetRect 的宽高比和位图原始宽高比不一致图像会被拉伸变形所以计算目标矩形时最好保持等比。2.3 位图图标的三个物理特性透明通道、原始尺寸与 DPI图标之所以是打印场景里的特殊对象是因为它有三个和普通图片不太一样的物理特性。第一个是透明通道。.ico 文件里的图标帧通常是 32 位 ARGB带 Alpha 通道。在屏幕上Alpha 通道负责和背景做混合在打印渲染里如果打印机驱动不支持透明混合Alpha 区域就会被当成 0 处理——也就是说透明像素可能被渲染成黑色。这就是「图标打出黑块」的根源。第二个是原始尺寸极小。图标一般是 16×16、32×32、48×48 像素。打印到纸上一个 10mm×10mm 的图标在 600 DPI 打印机上需要约 236×236 物理像素也就是要把 32×32 的图放大 7 倍以上。放大必然插值插值算法不好就模糊、锯齿。第三个是 DPI 标注混乱。很多 .ico 文件内部的 BITMAPINFOHEADER 里 DPI 字段是 0 或者 72WPF 解码时如果取不到有效 DPI会按 96 处理。同一个图标在不同机器上解出来的像素尺寸可能不一样打印尺寸计算就不能只依赖 ImageSource 的 Width/Height 属性要先确认像素尺寸再换算。// 读取图标的真实像素尺寸不信任ImageSource的Width/Height private Size GetBitmapPixelSize(ImageSource source) { BitmapSource bitmap source as BitmapSource; if (bitmap null) return Size.Empty; // 关键PixelWidth是原始像素Width是DIP单位 return new Size(bitmap.PixelWidth, bitmap.PixelHeight); }这里PixelWidth/PixelHeight才是位图真实的像素宽高而Width/Height是经过 DPI 换算后的 DIP 尺寸。打印尺寸计算必须以像素尺寸为基准否则在 DPI 标注异常的图标上会算错打印大小。3. 实现一个可复用的打印与预览模块核心代码与参数说明3.1 XPS 预览通道把 Visual 写成 FixedDocumentSequence 的完整代码预览的核心思路把要打印的 Visual 写入 XPS 文档再用DocumentViewer展示。DocumentViewer自带缩放、翻页、打印按钮省去自己造轮子。using System.IO; using System.IO.Packaging; using System.Windows.Documents; using System.Windows.Xps.Packaging; using System.Windows.Xps; // 把Visual对象写入内存中的XPS文档返回给DocumentViewer展示 private FixedDocumentSequence CreateXpsPreview(Visual content, Size pageSize) { // 1. 用内存流承载XPS包避免写临时文件 MemoryStream stream new MemoryStream(); Package package Package.Open(stream, FileMode.Create, FileAccess.ReadWrite); // 2. 创建XPS文档对象 XpsDocument xpsDoc new XpsDocument(package); // 3. 创建写入器把Visual序列化成XPS内容 XpsDocumentWriter writer XpsDocument.CreateXpsDocumentWriter(xpsDoc); writer.Write(content); // 4. 从XPS文档中取出固定文档序列 FixedDocumentSequence sequence xpsDoc.GetFixedDocumentSequence(); // 注意此处的xpsDoc和stream不能DisposeDocumentViewer还持有文档引用 return sequence; }使用时有几个关键点。writer.Write(content)会触发一次完整渲染内容包括视觉树里的所有元素、特效、剪辑区域渲染后的数据变成 XPS 的矢量描述。这意味着预览和最终打印的数据来源是同一份 XPS不存在两套渲染路径的偏差。pageSize不是必须参数因为Write(Visual)使用的是 Visual 自己的尺寸但如果你要打印的 Visual 没有明确设置尺寸比如直接 new 出来的 Grid渲染结果可能是一片空白所以建议提前给 Visual 设置好 Width/Height。DocumentViewer的绑定代码如下// 创建预览窗口并装载XPS文档 DocumentViewer viewer new DocumentViewer(); viewer.Document CreateXpsPreview(mainPanel, new Size(794, 1122)); viewer.FitToWidth(); previewContainer.Children.Add(viewer);FitToWidth()是 DocumentViewer 自带的布局方法让预览页自动适配窗口宽度用户体验比手动拖滚动条好得多。这个预览通道对单页和多页都有效多页内容会被自动拆成多个 XPS 页面DocumentViewer 底部会出现页码导航。3.2 图标打印关键代码DrawingVisual 与 PrintDialog 组合打印部分我用 DrawingVisual 组装整页内容再调用 PrintDialog 输出。这样每个元素的位置都是精确计算过的不依赖布局容器。// 组装一页打印内容标题、图标、文字 private DrawingVisual ComposePage(string title, ListIconItem items) { DrawingVisual page new DrawingVisual(); using (DrawingContext dc page.RenderOpen()) { // 画白色背景防止透明区域打出黑色 dc.DrawRectangle(Brushes.White, null, new Rect(0, 0, 794, 1122)); // 画标题 FormattedText titleText new FormattedText( title, CultureInfo.GetCultureInfo(zh-cn), FlowDirection.LeftToRight, new Typeface(Microsoft YaHei), 24, Brushes.Black, VisualTreeHelper.GetDpi(page).PixelsPerDip); dc.DrawText(titleText, new Point(48, 48)); // 逐个画图标和文字 double y 100; foreach (IconItem item in items) { // 图标画到 32x32 DIP 的矩形里 Rect iconRect new Rect(48, y, 32, 32); dc.DrawImage(item.IconSource, iconRect); // 文字接着图标右侧画 FormattedText itemText new FormattedText( item.Name, CultureInfo.GetCultureInfo(zh-cn), FlowDirection.LeftToRight, new Typeface(Microsoft YaHei), 14, Brushes.Black, VisualTreeHelper.GetDpi(page).PixelsPerDip); dc.DrawText(itemText, new Point(92, y 8)); y 48; } } return page; }这里有一个容易忽略的细节FormattedText构造函数里的pixelsPerDip参数。在 4K 屏或者系统缩放比例不是 100% 的机器上如果不传入VisualTreeHelper.GetDpi(page).PixelsPerDip文字会出现模糊或位置偏移。这个参数是 .NET Framework 4.6.2 之后引入的老代码里常见的写法是直接给 1.0在 150% 缩放下就会翻车。打印调用// 弹出打印对话框输出刚才组装好的页面 private void PrintPage(DrawingVisual pageVisual) { PrintDialog dialog new PrintDialog(); if (dialog.ShowDialog() true) { dialog.PrintVisual(pageVisual, 设备巡检单); } }PrintVisual对 DrawingVisual 的处理和对普通控件的处理完全一致它会读取 Visual 的尺寸作为打印页尺寸。所以如果页面内容没有铺满整张 A4打印出来的纸张尺寸会跟着变小——这是个常见误解很多人以为 PrintVisual 会自动匹配打印机默认纸张实际上它匹配的是 Visual 的边界。需要打满 A4 时要么把 DrawingVisual 的尺寸精确设为 A4 的 DIP 尺寸要么在打印前设置PrintTicket.PageMediaSize。3.3 打印坐标换算像素、DIP 与毫米之间的转换函数打印时最常踩的坑是单位混用。屏幕上的 WPF 元素用 DIP位图需要看像素打印参数又用毫米或英寸三套单位之间必须有明确的换算层。// 单位换算工具类毫米、DIP、像素之间互转 public static class PrintUnits { // 1英寸 25.4毫米 96 DIP private const double DipsPerInch 96.0; private const double MmPerInch 25.4; // 毫米转DIP用于计算打印页上的目标尺寸 public static double MmToDip(double mm) { return mm / MmPerInch * DipsPerInch; } // 像素转DIP根据位图的DPI转换为设备无关单位 public static double PixelsToDip(int pixels, double dpi) { return pixels * DipsPerInch / dpi; } // 像素转毫米计算位图的实际物理尺寸 public static double PixelsToMm(int pixels, double dpi) { return pixels / dpi * MmPerInch; } }使用时要注意PixelsToDip中的 dpi 是位图自身的 DPI不是屏幕的 DPI。很多 .ico 文件的 DPI 信息不可靠建议统一用 96 作为默认值打印时再根据实际需要放大。图标打印的推荐流程是先读PixelWidth和PixelHeight用PixelsToMm算出物理尺寸再乘以目标放大倍数得到打印尺寸最后用MmToDip转成 DrawingContext 里的坐标单位。这套换算层还有一个好处当你需要把打印内容从 A4 换成 Letter 纸张时只需要改页面尺寸参数不需要动图标绘制代码。我习惯把所有硬编码尺寸集中到一个PageLayout静态类里页面边距、图标尺寸、行距全部定义成常量改一处全部生效。// 页面布局常量集中管理打印参数 public static class PageLayout { public const double PageWidthDip 794; // A4 210mm水平方向 public const double PageHeightDip 1122; // A4 297mm垂直方向 public const double MarginDip 48; // 页边距 12.7mm public const double IconSizeDip 32; // 图标打印尺寸 8.5mm public const double LineHeightDip 48; // 行距 12.7mm }4. 打印图标的三个必调参数清晰度、页面尺寸与缩放策略4.1 RenderOptions.BitmapScalingMode用 Fant 还是 HighQuality位图缩放模式是打印图标清晰度最直接的参数。WPF 提供四种BitmapScalingModeNearestNeighbor最近邻、LowQuality低质量双线性、HighQuality高质量双三次、Fant更好的双三次加上预先模糊处理。对打印来说我实测下来最优选择是Fant。它在缩小和放大两方面都表现稳定边缘过渡柔和不会有双线性那种明显的生硬感。HighQuality在放大倍数较大时偶尔出现轻微波纹而Fant会预先对源图像做模糊再缩放视觉上更平滑。设置方式有两种一种是给Image控件附加属性另一种是在 DrawingVisual 的渲染上下文中设置RenderOptions。由于我们用 DrawingVisual 画图标设置在 DrawingContext 上不生效需要借助RenderOptions.SetBitmapScalingMode附加在 Visual 对象上。// 给DrawingVisual设置高质量缩放模式 DrawingVisual visual BuildIconVisual(iconSource, new Rect(0, 0, 32, 32)); RenderOptions.SetBitmapScalingMode(visual, BitmapScalingMode.Fant); RenderOptions.SetEdgeMode(visual, EdgeMode.Aliased);EdgeMode.Aliased的作用是关闭抗锯齿。这个设置有点反直觉——我们刚说需要抗锯齿让边缘平滑为什么又要关掉原因是EdgeMode.Aliased只影响形状边缘不影响位图内部的插值。在打印里形状边缘的抗锯齿会引入半透明像素某些驱动下这些像素变成灰色噪点关闭后边缘更干净。如果图标是矢量图形而不是位图BitmapScalingMode不生效需要改用SnapsToDevicePixels或者直接调整坐标到整数位置。矢量图标在打印时天然清晰不受像素插值影响这也是为什么越来越多项目把图标从 .ico 换成 SVG 或 Path 描述。4.2 PrintTicket 参数OutputQuality 与 OutputColor 怎么设PrintDialog 默认的打印质量是LetterQuality对于文字为主的文档够用但打图标和照片必须调高。PrintTicket里的OutputQuality和OutputColor是两个必须显式设置的参数。// 配置打印机的输出质量与色彩模式 private void ConfigurePrintTicket(PrintDialog dialog) { PrintTicket ticket new PrintTicket(); // 照片质量让位图插值使用更高质量的算法 ticket.OutputQuality OutputQuality.Photographic; // 彩色输出彩色打印机强制Color黑白打印机设GrayScale更合适 ticket.OutputColor OutputColor.Color; // 页面方向图标清单通常竖版 ticket.PageOrientation PageOrientation.Portrait; // 应用配置 dialog.PrintTicket ticket; }需要注意PrintTicket是设备相关的不是所有打印机都支持所有选项。比如某些型号的打印机不认Photographic此时设置会被忽略不会报错但也不生效。稳妥的做法是在应用配置后读回ticket.GetXmlStream()检查实际生效值。另外OutputColor要区分两种情况打印机支持彩色就设Color不支持就设GrayScale。如果设了Color而打印机是黑白激光驱动会自行转换彩色图标打出来是灰阶这是硬件限制软件层面改不了。如果打印机支持彩色但默认走黑白那大概率是驱动设置里没改需要去系统打印首选项里调。4.3 缩放策略Uniform、UniformToFill 对图标布局的影响图标画进目标矩形时Stretch行为决定了缩放方式。DrawingContext.DrawImage 不接受 Stretch 参数它会直接把位图拉伸到目标矩形——如果目标矩形和位图宽高比不一致图标就会变形。所以缩放策略要在计算目标矩形时提前处理好。三种策略的实际效果Uniform保持宽高比让位图完整落在矩形内两边留白UniformToFill保持宽高比填满矩形但会裁剪溢出部分None不做缩放位图多大就打多大。// 计算等比缩放的绘制矩形避免图标变形 private Rect CalculateUniformRect(ImageSource source, Size targetSize) { BitmapSource bitmap source as BitmapSource; if (bitmap null) return new Rect(targetSize); double sourceRatio bitmap.PixelWidth / (double)bitmap.PixelHeight; double targetRatio targetSize.Width / targetSize.Height; if (sourceRatio targetRatio) { // 源图更宽宽度撑满高度按比例 double height targetSize.Width / sourceRatio; return new Rect(0, (targetSize.Height - height) / 2, targetSize.Width, height); } else { // 源图更高高度撑满宽度按比例 double width targetSize.Height * sourceRatio; return new Rect((targetSize.Width - width) / 2, 0, width, targetSize.Height); } }这个函数模拟了Uniform的行为。对图标打印我建议始终用等比缩放因为设备图标的长宽比一旦被拉伸现场识别很容易出错——把瘦长的路由器图标拉成方形看着就像另一类设备。有些开发者图省事直接用new Rect(0, 0, iconSize, iconSize)画方形目标矩形遇到非方形图标就变形这是打印验收时很容易被忽略的问题。5. 打印与预览避坑五个真实翻车现场与排查路径5.1 图标打出来是黑块现象预览窗口里图标正常打印出来的图标位置是一块纯黑矩形轮廓还在里面的图案完全看不见。原因图标位图带透明通道打印驱动在处理 Alpha 混合时把透明像素渲染成了黑色。这个问题在低端打印机驱动和部分 PDF 虚拟打印机上尤其常见。另外如果源头 .ico 文件本身是 24 位位图格式不带 Alpha某些解码器会把未定义区域填充成黑色打印时同样翻车。解决第一层在 DrawingContext 里先画白色背景矩形垫底确保透明区域下面有实际颜色透明像素混合后还是白色第二层用FormatConvertedBitmap把图标通道转换成带 Alpha 的 32 位格式消除源文件格式问题。// 把任意位图规范化为32位带Alpha格式避免透明通道异常 private BitmapSource NormalizeIcon(ImageSource source) { BitmapSource bitmap source as BitmapSource; if (bitmap null) return null; // 关键是Pbgra32预乘Alpha打印渲染更稳定 FormatConvertedBitmap converted new FormatConvertedBitmap( bitmap, PixelFormats.Pbgra32, null, 0); return converted; }Pbgra32是预乘 Alpha 格式WPF 内部渲染效率最高打印驱动对它的兼容性也最好。转换后画到 DrawingVisual 里黑块问题基本绝迹。5.2 预览正常打印却整体偏移现象DocumentViewer 里看内容居中、页边距正常打印到纸上内容整体往右下方偏移了 5mm 到 10mm有时候左边的内容被裁掉一块。原因打印机存在物理可打印区域硬边距不同品牌型号的可打印区域不一样。比如某型号激光打印机左右物理边距是 4.2mm上下是 4.0mm如果你的 DrawingVisual 页面尺寸正好等于纸张幅面打印机再叠加自己的物理边距内容就被推挤偏移了。预览不偏移是因为 XPS 渲染不经过打印机驱动不会叠加硬边距。解决打印页面的 DIP 尺寸应该等于「纸张幅面减去物理边距」而不是等于纸张幅面。A4 的物理尺寸是 210×297mm减去上下左右各 5mm 硬边距后实际可用区域是 200×287mm换算成 DIP 大约是 756×1085。把 DrawingVisual 的宽高设为这个值内容就不会被硬边距挤出去。还有一个更稳妥的办法打印前读取打印机支持的PrintCapabilities动态获取可打印区域。// 读取打印机可打印区域动态修正页面尺寸 private Size GetPrintableArea(PrintDialog dialog) { PrintCapabilities capabilities dialog.PrintQueue.GetPrintCapabilities(dialog.PrintTicket); // 可打印区域单位是英寸换算成DIP double widthDip capabilities.PageImageableArea.ExtentWidth * 96.0; double heightDip capabilities.PageImageableArea.ExtentHeight * 96.0; return new Size(widthDip, heightDip); }PageImageableArea返回的ExtentWidth/ExtentHeight就是去除硬边距后的可用尺寸OriginWidth/OriginHeight是边距本身的偏移量。动态获取比写死 A4 尺寸更可靠换打印机时不用改代码。5.3 多页打印时图标串页现象打三页清单第一页的最后一个图标和第二页开头的图标重复出现或者某一页的图标错位到上一页的位置。原因多页打印时复用了同一个 DrawingVisual 对象。WPF 的 Visual 只能有一个父级每次写入 XPS 或打印后Visual 的渲染状态可能被修改如果循环里反复用同一个引用上一次的布局状态残留到下一次就会出现串页。解决每页都新建 DrawingVisual或者在写完后立即释放引用。我习惯把页面组装逻辑封装成一个方法按页码生成新的 Visual而不是复用实例。// 多页打印每页独立生成Visual绝不复用 public void PrintMultiPage(ListIconItem allItems) { PrintDialog dialog new PrintDialog(); if (dialog.ShowDialog() ! true) return; Size pageSize GetPrintableArea(dialog); int currentIndex 0; int pageNumber 0; ListVisual pages new ListVisual(); // 按每页最多20项拆分 while (currentIndex allItems.Count) { int count Math.Min(20, allItems.Count - currentIndex); ListIconItem pageItems allItems.GetRange(currentIndex, count); // 每一页都new一个DrawingVisual DrawingVisual page ComposePage($第{pageNumber 1}页, pageItems, pageSize); pages.Add(page); currentIndex count; pageNumber; } // 用DocumentPaginator统一输出 dialog.PrintDocument(new VisualsPaginator(pages, pageSize), 设备清单); }这里的VisualsPaginator是一个简单的DocumentPaginator实现内部持有 Visual 列表按页索引返回对应 DocumentPage。分页逻辑和页面生成逻辑分离后续要调整每页行数只改count这一个参数。5.4 彩色图标打印成灰阶现象彩色打印机代码里也设了OutputColor.Color但打出来还是灰的。原因三种可能。第一打印机的默认打印首选项被设成了黑白/灰阶PrintTicket 的 Color 设置被驱动忽略第二PrintTicket 和 PrintQueue 的默认设置冲突你没把 ticket 关联到 queue第三源图标本身是灰度位图解码后没有彩色信息彩色打印机也打不出颜色。解决第一个问题去系统打印首选项改代码层面拦不住可以在界面提示用户检查第二个问题把 ticket 显式关联到 queue第三个问题检查源文件用PixelFormats.Bgra32重新转换并确认转换后确实包含非灰度像素。// 显式把PrintTicket绑定到打印队列 PrintDialog dialog new PrintDialog(); dialog.PrintQueue LocalPrintServer.GetDefaultPrintQueue(); dialog.PrintTicket dialog.PrintQueue.DefaultPrintTicket.Clone(); dialog.PrintTicket.OutputColor OutputColor.Color; dialog.PrintTicket.OutputQuality OutputQuality.Photographic;注意Clone()是必要的。直接修改DefaultPrintTicket会改到打印队列的全局设置影响其他所有打印任务克隆出一份新的再改不影响系统默认值。这个坑是某次排查很久之后发现的因为当时改的似乎是全局设置其他打印任务也跟着变了。5.5 后台线程调用打印直接崩溃现象把打印逻辑放到 Task 里执行界面不卡了但程序时不时抛异常异常信息和 COM 组件有关或者提示调用线程必须 STA。原因WPF 的打印链路底层依赖系统打印框架要求线程必须是 STA单线程单元而且很多 Visual 相关的操作只能在创建它的线程上执行。后台 Task 默认跑在线程池线程线程池线程是 MTA直接调用PrintDialog.PrintVisual必然不稳定。此外如果后台线程访问了主线程创建的 Visual 对象跨线程访问本身就违反 WPF 线程规则。解决所有打印和预览操作集中到 UI 线程执行。如果打印内容准备耗时较长可以分两步后台线程只做数据准备读图标、组装文字列表UI 线程只做最终渲染和打印。// 后台准备数据UI线程执行打印 private async void PrintButton_Click(object sender, RoutedEventArgs e) { // 1. 后台线程准备打印数据不涉及Visual对象 ListIconItem items await Task.Run(() LoadPrintData()); // 2. 回到UI线程此时上下文自动切回 await Dispatcher.InvokeAsync(() { PrintDialog dialog new PrintDialog(); if (dialog.ShowDialog() true) { DrawingVisual page ComposePage(设备清单, items, PageLayout.PageWidthDip, PageLayout.PageHeightDip); dialog.PrintVisual(page, 设备清单); } }); }Dispatcher.InvokeAsync把打印代码调度回 UI 线程执行。这里的关键认知是WPF 打印不是纯计算任务它要和打印机驱动交互必须遵守同步上下文规则。把打印放后台线程的意义仅仅在于不阻塞界面但前提是线程模型正确。6. 进阶两个让预览更可信的小技巧6.1 用 VisualBrush 做实时缩略图DocumentViewer 的预览是精确的 XPS 渲染但有时候文档还没生成或者你只想在打印前快速瞄一眼图标排布此时VisualBrush是最省事的方案。它可以直接引用任意 Visual实时呈现其渲染内容不需要经过 XPS 中间层。// 用VisualBrush做缩略图所见即所得 private void CreateThumbnail(Visual sourceVisual, FrameworkElement previewHost) { VisualBrush brush new VisualBrush(sourceVisual); brush.Stretch Stretch.Uniform; brush.AlignmentX AlignmentX.Center; brush.AlignmentY AlignmentY.Center; Rectangle previewRect new Rectangle(); previewRect.Width 200; previewRect.Height 280; previewRect.Fill brush; previewHost.Children.Add(previewRect); }VisualBrush 的优点是零拷贝——它持有 Visual 的引用源 Visual 的任何变化都会实时反映到缩略图上。如果用户改了图标尺寸或换了一张图预览立刻更新不需要重新生成 XPS。缺点是 VisualBrush 不参与纸张分页它只适合做单页内容的缩略预览多页场景还是老实走 XPS 通道。6.2 打印结果验证清单每次调整打印参数后用这套清单做验收比反复试打快得多。第一条检查图标的边缘用放大镜功能查看图标边缘是否平滑如果出现锯齿或马赛克检查 BitmapScalingMode 是否设为 Fant。第二条检查图标颜色和屏幕上的原始图标对比色差过大时检查打印驱动色彩管理和 OutputColor 设置。第三条检查位置偏移用直尺量图标到页边的距离和代码里的 MarginDip 对比偏差超过 1mm 说明硬边距没处理好。第四条检查透明区域白色背景的页面上图标周围如果出现灰色晕圈说明 Alpha 混合仍然有问题需要重新做 Pbgra32 转换。第五条检查多页连续打印连续打 5 页以上确认没有串页和内容丢失。这套清单是我换过三台不同品牌打印机之后归纳出来的基本覆盖了所有实际遇到过的输出异常。现在的习惯是每次改代码前先打一张基准页存档改完参数再打一张两张对比着看问题出在代码还是出在驱动一眼就能分辨。打印这种功能线上跑一年没问题的代码换一台打印机就可能翻车保持对比验证的习惯比任何代码技巧都重要。希望帮到你。本文还有配套的精品资源点击获取