恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
WPF票据打印实战:FixedDocument精确控制与针式打印机避坑指南
首页
资讯中心
/
WPF票据打印实战:FixedDocument精确控制与针式打印机避坑指南
WPF票据打印实战:FixedDocument精确控制与针式打印机避坑指南
发布时间:2026/10/20 23:42:42
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里放FixedPageFixedPage里用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); // 单位是DIP1/96英寸注意这里的单位是DIP设备无关像素1英寸96 DIP。241mm换算过来是241/25.4*96≈910.87 DIP。如果你直接填241打印机收到的是241 DIP≈63.7mm纸就设小了。这个单位换算我见过太多人栽跟头一定要记牢。边距设置用PageBorderless和PageMarginticket.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) { // 逐行添加 } // 添加页脚如第i1页共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 ListTicketItem 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; }第二步写渲染器把数据转成FixedDocumentpublic 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); }这个文件可以直接双击打开排版效果和实际打印基本一致是排查坐标问题的利器。