1. 为什么WPF打印票据值得单独拎出来聊
做桌面端业务系统的朋友大概率都碰过票据打印这个需求。销售单、出库单、收银小票、快递面单、缴费凭证,形态五花八门,但底层诉求高度一致:格式固定、批量输出、针式或热敏打印机、要能精确控制边距和分页。WPF作为微软主推的桌面UI框架,在业务系统里出场率很高,可一旦涉及打印,很多人第一反应还是去翻WinForm时代的PrintDocument,或者干脆绕道生成PDF再调用外部程序。绕一圈下来,代码丑、维护难、对齐还老出问题。
WPF其实自带一套完整的打印体系,核心是System.Printing命名空间配合PrintDialog、PrintQueue、DocumentPaginator这些类。它和WPF的矢量渲染天然契合,意味着你在界面上看到的FlowDocument、FixedDocument、甚至任意Visual,都能一比一打到纸上,分辨率不丢、字体不糊。这套机制解决的核心问题就是:让票据的屏幕预览和实际打印结果保持一致,同时把分页、边距、打印机选择这些脏活交给框架处理。
这篇文章适合谁看?如果你正在用WPF做ERP、零售、医疗、物流这类有票据输出需求的系统,或者你被针式打印机的走纸偏移折磨过,那接下来的内容基本能覆盖你80%的坑。我会从整体设计思路讲到具体代码,再到实际调试中那些文档里不会写的经验,尽量让你看完就能动手改自己项目里的打印模块。
2. 票据打印的整体设计思路与方案选型
2.1 三种主流实现路径的取舍
在WPF里做票据打印,市面上常见的路子大概三条,我先把它们摆出来对比,你再决定走哪条。
| 方案 | 核心做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Visual打印 | 直接把界面元素或构造的Visual传给PrintDialog | 代码少、所见即所得 | 分页控制弱、复杂票据难拆页 | 单页小票、简单凭证 |
| FlowDocument | 用流文档组织内容再打印 | 自动分页、排版灵活 | 精确坐标控制麻烦 | 文字为主的单据 |
| FixedDocument | 固定页面+绝对定位 | 像素级控制、分页明确 | 需要手动算坐标 | 套打、多联票据、针式打印 |
我个人的经验是:收银小票、快递面单这类对位置要求苛刻的,直接上FixedDocument;纯文字的通知单、对账单,FlowDocument更省心;如果只是临时打个预览图,Visual方案够用。很多人一上来就用FlowDocument,结果发现针式打印机走纸对不齐,又回头重构,这个弯路我见得太多了。
2.2 为什么FixedDocument是票据场景的最优解
票据的本质是什么?是在固定尺寸的纸张上,把数据填到固定坐标的格子里。这跟网页排版是两回事,网页可以流式布局,票据不行,金额栏就得在那一列,多一个字少一个字都可能串位。FixedDocument的模型正好匹配这个需求:一个Document由多个Page组成,每个Page里放FixedPage,FixedPage里用Canvas做绝对定位,你想把文字放哪个坐标就放哪个坐标。
更重要的是,FixedDocument对套打支持极好。所谓套打,就是纸上已经印好了表格线框,你只需要把数据打到对应的空位里。这种场景下,坐标精度直接决定成败,FixedDocument配合PrintTicket里的页边距设置,能把偏差控制在一毫米以内。针式打印机虽然老,但在多联复写场景下无可替代,而它恰恰最吃坐标精度。
2.3 打印流程的骨架拆解
不管选哪种方案,WPF打印的流程骨架是固定的,理解了这个骨架,后面填肉就顺了。
- 构造打印内容:把数据绑定到Visual、FlowDocument或FixedDocument上。
- 获取打印对话框:
PrintDialog负责让用户选打印机、设份数、调页面范围。 - 配置打印票据:通过
PrintTicket设置纸张大小、边距、双面等参数。 - 提交打印任务:调用
PrintDocument或PrintVisual,把内容交给打印队列。 - 处理分页:多页内容需要自己实现
DocumentPaginator或手动构造多个Page。
这里面第3步和第5步是重灾区。纸张大小如果没设对,打印机可能按默认A4走纸,票据直接打歪;分页如果没处理好,第二页的数据可能跑到第一页去。后面我会逐个拆开讲。
3. 核心细节解析与实操要点
3.1 打印对话框的正确打开方式
PrintDialog看着简单,但有几个细节不注意就会踩坑。先看基础代码:
var printDialog = new PrintDialog(); if (printDialog.ShowDialog() == true) { // 用户点了确定 PrintQueue queue = printDialog.PrintQueue; PrintTicket ticket = printDialog.PrintTicket; // 后续打印逻辑 }这里第一个坑是对话框的 owner。如果你不设置owner,对话框可能跑到主窗口后面去,用户以为程序卡死了。正确做法是传当前窗口:
var printDialog = new PrintDialog(); printDialog.ShowDialog(); // 无owner版本,慎用实际上WPF的PrintDialog.ShowDialog()没有直接接受owner的重载,但你可以通过设置WindowInteropHelper或者干脆在调用前把主窗口置顶来规避。我通常的做法是在打印前先this.Activate(),确保主窗口在前台。
第二个坑是PrintTicket的复用。PrintDialog返回的PrintTicket是用户当前选择的配置,但如果你在代码里手动改了PrintTicket的属性,一定要重新赋值回去,否则不生效:
PrintTicket ticket = printDialog.PrintTicket; ticket.PageMediaSize = new PageMediaSize(PageMediaSizeName.ISOA4); printDialog.PrintTicket = ticket; // 关键:重新赋值这个细节文档里写得含糊,我当初调了半天才发现改了没生效,就是因为漏了重新赋值这一步。
3.2 纸张尺寸与边距的精确控制
票据打印最头疼的就是纸张。针式打印机常用的纸张有241mm×140mm(二等分)、241mm×93mm(三等分)、210mm×140mm等,热敏小票常见58mm和80mm宽。这些非标准尺寸在PageMediaSize里未必有对应的枚举值,这时候要用自定义尺寸:
var ticket = printDialog.PrintTicket; ticket.PageMediaSize = new PageMediaSize(241, 140); // 单位是DIP,1/96英寸注意这里的单位是DIP(设备无关像素),1英寸=96 DIP。241mm换算过来是241/25.4*96≈910.87 DIP。如果你直接填241,打印机收到的是241 DIP≈63.7mm,纸就设小了。这个单位换算我见过太多人栽跟头,一定要记牢。
边距设置用PageBorderless和PageMargin:
ticket.PageBorderless = PageBorderless.Borderless; ticket.PageMargin = new PageMargin(0, 0, 0, 0); // 左、上、右、下但这里有个现实问题:不是所有打印机都支持无边框。针式打印机通常有物理边距,你设成0它也不认,实际打印时还是会留白。所以更稳妥的做法是设一个小的安全边距,比如2mm,然后在FixedPage里把内容坐标相应偏移。
3.3 FixedDocument的构造与数据绑定
构造FixedDocument的标准流程是这样的:
var fixedDoc = new FixedDocument(); var page = new FixedPage(); page.Width = 910.87; // 241mm page.Height = 529.13; // 140mm var canvas = new Canvas(); canvas.Width = page.Width; canvas.Height = page.Height; // 添加文本 var textBlock = new TextBlock(); textBlock.Text = "销售单号:SO20240101001"; textBlock.FontFamily = new FontFamily("宋体"); textBlock.FontSize = 12; Canvas.SetLeft(textBlock, 50); Canvas.SetTop(textBlock, 30); canvas.Children.Add(textBlock); page.Children.Add(canvas); var pageContent = new PageContent(); ((IAddChild)pageContent).AddChild(page); fixedDoc.Pages.Add(pageContent);这段代码有几个要点。第一,PageContent必须通过IAddChild接口添加FixedPage,直接赋值Child属性在某些版本上会报错。第二,Canvas.SetLeft和SetTop是附加属性,别写成textBlock.Left,那个不存在。第三,字体建议显式指定,不要依赖系统默认,否则换台机器可能字体就变了,票据排版跟着乱。
数据绑定方面,如果你用MVVM,可以把数据先组织成ViewModel,然后在构造TextBlock时绑定。但票据场景下数据量通常不大,直接代码构造反而更直观、更好调试。我一般会写一个TicketRenderer类,输入数据对象,输出FixedDocument,职责清晰。
3.4 多页票据的分页策略
一张票据打不下怎么办?比如订单明细有50行,一页只能放20行。这时候需要手动分页:
int rowsPerPage = 20; int totalPages = (int)Math.Ceiling(items.Count / (double)rowsPerPage); for (int i = 0; i < totalPages; i++) { var page = new FixedPage(); // 设置尺寸 var canvas = new Canvas(); // 添加表头 // 添加当前页的数据行 var pageItems = items.Skip(i * rowsPerPage).Take(rowsPerPage); foreach (var item in pageItems) { // 逐行添加 } // 添加页脚,如"第i+1页,共totalPages页" // 组装PageContent }分页的关键是表头重复和页码标注。票据跨页时,第二页如果没有表头,读的人根本不知道哪列是哪列。所以每页都要重新画表头。页码也要标清楚,方便核对。
还有一个细节:最后一页的空白处理。如果最后一页只有3行数据,剩下的空间是留白还是补空行?针式打印机连续走纸的话,留白可能导致撕纸位置不对。我的做法是补空行到固定行数,保证每页高度一致,这样走纸才稳定。
4. 实操过程与核心环节实现
4.1 从零搭建一个票据打印模块
假设我们要做一个销售出库单的打印功能,纸张用241mm×140mm二等分,针式打印机,需要打印单号、日期、客户、明细表格、合计金额、制单人。
第一步,定义数据模型:
public class SalesTicket { public string TicketNo { get; set; } public DateTime PrintDate { get; set; } public string CustomerName { get; set; } public List<TicketItem> Items { get; set; } public decimal TotalAmount { get; set; } public string Operator { get; set; } } public class TicketItem { public string ProductName { get; set; } public string Spec { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal Amount => Quantity * UnitPrice; }第二步,写渲染器,把数据转成FixedDocument:
public class SalesTicketRenderer { private const double PageWidth = 910.87; // 241mm private const double PageHeight = 529.13; // 140mm private const double MarginLeft = 20; private const double MarginTop = 15; private const int RowsPerPage = 8; public FixedDocument Render(SalesTicket ticket) { var doc = new FixedDocument(); int totalPages = Math.Max(1, (int)Math.Ceiling(ticket.Items.Count / (double)RowsPerPage)); for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { var page = BuildPage(ticket, pageIndex, totalPages); var content = new PageContent(); ((IAddChild)content).AddChild(page); doc.Pages.Add(content); } return doc; } private FixedPage BuildPage(SalesTicket ticket, int pageIndex, int totalPages) { var page = new FixedPage { Width = PageWidth, Height = PageHeight }; var canvas = new Canvas { Width = PageWidth, Height = PageHeight }; double y = MarginTop; // 标题 AddText(canvas, "销售出库单", PageWidth / 2 - 40, y, 16, true); y += 30; // 单号、日期 AddText(canvas, $"单号:{ticket.TicketNo}", MarginLeft, y, 10); AddText(canvas, $"日期:{ticket.PrintDate:yyyy-MM-dd}", PageWidth - 200, y, 10); y += 20; // 客户 AddText(canvas, $"客户:{ticket.CustomerName}", MarginLeft, y, 10); y += 25; // 表头 string[] headers = { "商品名称", "规格", "数量", "单价", "金额" }; double[] colX = { MarginLeft, 250, 400, 500, 650 }; for (int i = 0; i < headers.Length; i++) { AddText(canvas, headers[i], colX[i], y, 10, true); } y += 20; // 明细行 var pageItems = ticket.Items .Skip(pageIndex * RowsPerPage) .Take(RowsPerPage) .ToList(); foreach (var item in pageItems) { AddText(canvas, item.ProductName, colX[0], y, 10); AddText(canvas, item.Spec, colX[1], y, 10); AddText(canvas, item.Quantity.ToString(), colX[2], y, 10); AddText(canvas, item.UnitPrice.ToString("F2"), colX[3], y, 10); AddText(canvas, item.Amount.ToString("F2"), colX[4], y, 10); y += 18; } // 补空行,保证每页高度一致 for (int i = pageItems.Count; i < RowsPerPage; i++) { y += 18; } // 合计 y += 10; AddText(canvas, $"合计金额:{ticket.TotalAmount:F2}", PageWidth - 300, y, 11, true); y += 25; // 制单人和页码 AddText(canvas, $"制单人:{ticket.Operator}", MarginLeft, y, 10); AddText(canvas, $"第{pageIndex + 1}页 / 共{totalPages}页", PageWidth - 200, y, 10); page.Children.Add(canvas); return page; } private void AddText(Canvas canvas, string text, double x, double y, double fontSize, bool bold = false) { var tb = new TextBlock { Text = text, FontFamily = new FontFamily("宋体"), FontSize = fontSize, FontWeight = bold ? FontWeights.Bold : FontWeights.Normal }; Canvas.SetLeft(tb, x); Canvas.SetTop(tb, y); canvas.Children.Add(tb); } }第三步,调用打印:
public void PrintSalesTicket(SalesTicket ticket) { var renderer = new SalesTicketRenderer(); var doc = renderer.Render(ticket); var printDialog = new PrintDialog(); if (printDialog.ShowDialog() != true) return; // 配置纸张 var ticketConfig = printDialog.PrintTicket; ticketConfig.PageMediaSize = new PageMediaSize(241, 140); ticketConfig.PageOrientation = PageOrientation.Portrait; printDialog.PrintTicket = ticketConfig; // 提交打印 printDialog.PrintDocument(doc.DocumentPaginator, "销售出库单"); }这套代码跑下来,基本能打出格式规整的票据。但实际部署时,还有几个环节需要额外处理。
4.2 打印预览的实现
用户点打印前总想先看看效果,WPF里做预览不难,把FixedDocument塞进DocumentViewer就行:
var viewer = new DocumentViewer(); viewer.Document = doc; var previewWindow = new Window { Title = "打印预览", Content = viewer, Width = 900, Height = 700 }; previewWindow.ShowDialog();但DocumentViewer默认会显示工具栏,包含打印按钮,如果你不想要,可以自定义模板或者用FlowDocumentScrollViewer替代。另外预览窗口的缩放比例要调好,241mm宽的票据在普通屏幕上显示会偏小,建议设置Zoom属性到150%左右。
4.3 针式打印机的特殊处理
针式打印机和激光打印机在WPF眼里都是打印队列,但实际行为差异很大。针式打印机通常不支持双面、不支持彩色、走纸靠连续纸,而且驱动对PageMediaSize的响应经常不标准。我遇到过设了241×140,结果打印机按A4走纸的情况,原因是驱动里没有对应的纸张类型。
解决办法有两个:一是在打印机驱动里预先添加自定义纸张,代码里用PageMediaSizeName去匹配;二是干脆不设PageMediaSize,让打印机用默认纸张,然后在FixedPage里按实际纸张尺寸排版,靠边距调整位置。第二种更稳,但需要你提前知道打印机的默认纸张是什么。
还有一个坑是打印速度。针式打印机慢,一份多页票据可能要打十几秒。如果你在UI线程同步调用PrintDocument,界面会卡死。正确做法是放到后台线程:
await Task.Run(() => { printDialog.PrintDocument(doc.DocumentPaginator, "销售出库单"); });注意PrintDialog本身是UI对象,ShowDialog必须在UI线程调,但PrintDocument可以放后台。这个拆分要理清楚。
5. 常见问题与排查技巧实录
5.1 打印偏移问题速查
打印偏移是票据场景最高频的问题,表现是内容整体偏左、偏上,或者逐页累积偏移。我整理了一个排查表:
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 整体偏左/偏上 | 打印机物理边距未计入 | 打印测试页量实际边距 | 在坐标里加上边距补偿 |
| 逐页累积偏移 | 走纸精度问题 | 观察多页打印的偏移趋势 | 每页重新定位,不依赖累积坐标 |
| 内容被截断 | 纸张尺寸设小了 | 检查PageMediaSize单位 | 确认DIP换算,1mm≈3.78 DIP |
| 字体忽大忽小 | 字体未显式指定 | 换机器测试 | 显式设置FontFamily和FontSize |
| 表格线错位 | 坐标计算用了整数 | 检查是否有取整操作 | 用double计算,最后再取整 |
偏移补偿这块,我的经验是先打一张空白测试页,量出实际可打印区域的左上角坐标,然后在所有内容坐标上加上这个偏移量。比如打印机实际左边距是5mm,那你的MarginLeft就要设成5mm对应的DIP值再加你想要的留白。
5.2 分页错乱的排查思路
分页错乱通常有三种表现:内容跑到页外、页码不对、表头丢失。排查顺序建议这样:
先确认RowsPerPage的计算是否正确。如果你的行高是18 DIP,页面可用高度是500 DIP,那最多放27行,但你还要留表头和页脚的空间,实际可能只能放20行。这个数要实打实算,不能拍脑袋。
再确认Skip和Take的边界。Skip(pageIndex * RowsPerPage)在最后一页可能返回空集合,如果你没处理,就会打出一张只有表头的空页。加个判断:
var pageItems = ticket.Items .Skip(pageIndex * RowsPerPage) .Take(RowsPerPage) .ToList(); if (pageItems.Count == 0 && pageIndex > 0) continue; // 跳过空页最后确认PageContent的添加顺序。FixedDocument.Pages是有序集合,添加顺序就是打印顺序,别搞反了。
5.3 打印机兼容性避坑
不同品牌打印机对WPF打印的支持程度差异很大。我踩过的坑包括:某品牌针式打印机不支持PageBorderless,设了无边框反而打印报错;某品牌热敏打印机对PageMediaSize的自定义尺寸响应迟钝,要重启打印服务才生效;还有的打印机在PrintTicket里设了份数,但实际只打一份,需要在驱动里再设一次。
应对策略是代码里做降级处理。比如设置PageBorderless时用try-catch包起来,失败了就跳过;份数设置同时改PrintTicket.CopyCount和打印队列的CurrentJobSettings。这些兼容性代码看着丑,但能省下大量现场调试时间。
提示:部署前一定要在目标打印机上做完整测试,包括单页、多页、连续打印、取消打印等场景。模拟器和实际打印机行为可能完全不同。
5.4 性能优化的几个实用技巧
票据打印通常不追求极致性能,但批量打印时还是要注意。第一,复用FixedDocument的构造逻辑,别每次打印都重新反射创建控件。第二,冻结可冻结对象,比如Brush、Pen这些,调用Freeze()能减少渲染开销。第三,批量打印时合并打印任务,如果一次要打100张票据,不要循环调用PrintDocument,而是构造一个包含100页的FixedDocument一次提交,这样打印队列只处理一个任务,效率高很多。
还有一个容易被忽略的点:内存释放。FixedDocument持有大量Visual对象,打印完后如果不释放,内存会持续增长。建议打印完成后把doc = null,并调用GC.Collect()(虽然不推荐频繁调,但打印场景下偶尔用一次可以接受)。
6. 我个人的实操心得与后续扩展
这套方案我在几个项目里反复用过,最大的体会是:票据打印的难点不在代码,而在对打印机行为的理解。同样的代码,换台打印机可能就要调坐标;同样的纸张设置,不同驱动反应不一样。所以我的建议是,打印模块一定要留出可配置的偏移参数,把坐标补偿做成配置项,现场调试时改配置就行,不用重新编译。
另外,如果你的系统需要支持多种票据模板,可以考虑把模板定义抽成XML或JSON,渲染器读取模板配置来构造FixedDocument。这样新增一种票据只需要加配置,不用改代码。这个扩展方向我在一个零售项目里实践过,效果不错,后续维护成本降了很多。
最后分享一个小技巧:调试打印时,可以先把FixedDocument渲染成XPS文件,用XPS查看器检查排版,确认无误再实际打印。这样能省下大量纸张,也避免反复打扰打印机。渲染XPS的代码很简单:
using (var xpsDoc = new XpsDocument("test.xps", FileAccess.ReadWrite)) { var writer = XpsDocument.CreateXpsDocumentWriter(xpsDoc); writer.Write(doc.DocumentPaginator); }这个文件可以直接双击打开,排版效果和实际打印基本一致,是排查坐标问题的利器。