1. 从一张小票说起:WPF打印票据到底难在哪
做过桌面端业务系统的人,大概率都绕不开一个需求:打印小票。餐饮的结账单、零售的购物小票、医院的取药单、停车场的收费凭证,形态各异,但底层逻辑高度一致——把一段结构化数据,按固定版式输出到热敏打印机或针式打印机上。WPF作为微软主推的桌面UI框架,做界面很舒服,可一旦涉及票据打印,很多人就开始头疼了。
我最早接触这个需求是在一个零售收银系统里。当时想得很简单:WPF有PrintDialog,有FlowDocument,把内容拼一拼打出去不就完了?结果第一版打出来,格式全乱,边距对不上,条码糊成一团,打印机还时不时报缺纸。后来才明白,票据打印和普通文档打印是两码事,它有自己的游戏规则。
这篇文章想聊的,就是WPF环境下打印票据这件事的完整落地思路。从打印对象的选型、版式设计、条码与二维码的绘制,到打印机通信、纸张规格适配、常见故障排查,我会把踩过的坑和验证过的方案都摊开讲。适合正在做收银、点单、排队叫号、物流面单这类系统的开发者,也适合刚接触WPF打印、被XpsDocument和PrintQueue绕晕的朋友。不管你是用热敏小票机还是针式打印机,核心思路是相通的。
先说一个反直觉的结论:在WPF里打印票据,最稳的方案往往不是用WPF自己的打印API,而是绕到底层用Win32的打印接口,或者干脆生成图片再打印。为什么?往下看。
2. 打印对象的三种选型:Visual、FlowDocument还是直接画图
WPF打印票据,第一步是决定"拿什么去打"。这个选择直接决定了后续版式控制的难易程度。我实测下来,主流有三条路,各有各的脾气。
2.1 用Visual/UIElement直接打印:所见即所得,但坑最多
WPF的PrintDialog.PrintVisual()是最直观的方式。你在界面上摆好一个Grid或者Canvas,把票据内容塞进去,然后直接把这个视觉元素丢给打印机。代码大概长这样:
PrintDialog pd = new PrintDialog(); if (pd.ShowDialog() == true) { pd.PrintVisual(myReceiptPanel, "打印小票"); }看起来很美,但实际用起来问题一堆。首先,PrintVisual打印的是屏幕上的视觉树,屏幕分辨率和打印机DPI是两套坐标系。你在1080P屏幕上看着正好的字号,打到203DPI的热敏纸上可能小得看不清。其次,PrintVisual默认会按打印机的可打印区域缩放,热敏打印机通常有2到3毫米的物理边距,缩放之后内容容易偏移。再者,如果你为了打印临时构造一个不在视觉树上的元素,还得手动Measure和Arrange,否则打出来是空白。
我踩过最深的坑是:用PrintVisual打条码,屏幕上显示正常,打出来条码的细条纹糊在一起,扫码枪根本读不出来。原因是热敏打印机的点阵精度和屏幕像素不是一回事,条码模块宽度必须按打印机的物理点来计算,不能按屏幕像素。
提示:
PrintVisual适合打印内容简单、对精度要求不高的场景,比如一张纯文字的告知单。票据类需求慎用。
2.2 用FlowDocument排版:文字流很爽,精确控制很难
FlowDocument是WPF为文档排版设计的,配合DocumentPaginator可以分页打印。它的强项是文字自动换行、段落样式统一,适合打印A4文档、报表这类内容。但票据打印要的是"精确到毫米"的定位,FlowDocument的流式布局反而成了负担。
比如你想让"合计金额"固定在票据底部往上2厘米的位置,用FlowDocument就得靠Paragraph的Margin去凑,换个纸张宽度全乱。而且FlowDocument对表格、图片、条码的支持比较别扭,条码得转成Image再塞进去,控制不了精确尺寸。
我的经验是:票据打印不要用FlowDocument。它的设计目标和你想要的结果是错位的。
2.3 用DrawingVisual手工绘制:精度最高,推荐方案
真正靠谱的方案是DrawingVisual。它不走视觉树,你直接用DrawingContext在指定坐标画线、画字、画图,完全掌控每一个像素的位置。配合PrintDialog.PrintVisual()或者更底层的XpsDocumentWriter,可以做到毫米级定位。
DrawingVisual dv = new DrawingVisual(); using (DrawingContext dc = dv.RenderOpen()) { // 按打印机DPI换算坐标 FormattedText title = new FormattedText( "销售小票", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("宋体"), 14, Brushes.Black, 203); dc.DrawText(title, new Point(50, 20)); // 继续画其他内容... }DrawingVisual的好处是:坐标系由你定,DPI由你控,条码可以用DrawingContext直接画矩形模块,精度完全可控。缺点是所有东西都得手动画,没有现成的布局控件帮你排版。但对于票据这种版式固定的场景,手动画反而更省心——反正版式就那么几行,写一次就固定了。
三种方案的对比我整理成表格,方便你按需选择:
| 方案 | 精度控制 | 开发效率 | 条码支持 | 适用场景 |
|---|---|---|---|---|
| PrintVisual | 低 | 高 | 差 | 简单告知单 |
| FlowDocument | 中 | 中 | 差 | A4报表文档 |
| DrawingVisual | 高 | 中 | 好 | 票据、面单 |
选型结论很明确:票据打印用DrawingVisual,把版式当成一张画布来画。
3. 坐标系与DPI换算:票据打印最容易翻车的地方
票据打印出问题,十有八九是坐标系没搞对。WPF的默认坐标单位是设备无关像素(DIP),1 DIP等于1/96英寸。而热敏打印机的分辨率通常是203 DPI,针式打印机常见的是180 DPI或360 DPI。这两套坐标系不换算,打出来的东西必然错位。
3.1 为什么必须做DPI换算
假设你要在票据上画一条宽度为1毫米的线。在96 DIP的坐标系里,1毫米约等于3.78 DIP。但打印机是203 DPI,1毫米等于8个物理点。如果你直接按DIP画,打印机收到的是3.78个点,四舍五入成4个点,实际宽度只有0.5毫米,细了一半。
字号的问题更明显。你想打12磅的字,在屏幕上12磅约等于16 DIP,但打印机需要按203 DPI重新计算。不换算的话,打出来的字要么太大溢出,要么太小看不清。
换算公式很简单:
物理点数 = DIP值 × (打印机DPI / 96)反过来,如果你想让某个元素在纸上占X毫米,先算物理点数,再除以(DPI/96)得到DIP值。
3.2 用PrintCapabilities拿到真实DPI
打印机的DPI不能靠猜,得从PrintQueue里读。WPF提供了PrintCapabilities,可以拿到打印机的各项能力:
PrintDialog pd = new PrintDialog(); PrintQueue pq = pd.PrintQueue; PrintCapabilities pc = pq.GetPrintCapabilities(); // 获取可打印区域 double printableWidth = pc.PageImageableArea.ExtentWidth; double printableHeight = pc.PageImageableArea.ExtentHeight; double originX = pc.PageImageableArea.OriginWidth; double originY = pc.PageImageableArea.OriginHeight;PageImageableArea告诉你打印机实际能打的范围,OriginWidth和OriginHeight是左上角的偏移量。热敏打印机通常有2到3毫米的不可打印边距,这个值必须考虑进去,否则内容会被裁掉。
注意:不同品牌的热敏打印机,
PageImageableArea返回的值可能不一样,甚至同一台机器装不同驱动返回的值都不同。所以这个值不能写死,必须在运行时读取。
3.3 一个实用的坐标换算工具类
我把换算逻辑封装成一个静态类,每次打印前初始化一次,后续所有坐标都走它:
public static class PrintUnit { public static double DpiX { get; private set; } = 203; public static double DpiY { get; private set; } = 203; public static double OriginX { get; private set; } public static double OriginY { get; private set; } public static void Init(PrintCapabilities pc) { OriginX = pc.PageImageableArea.OriginWidth; OriginY = pc.PageImageableArea.OriginHeight; // 从打印机能力推算DPI,或按机型预设 DpiX = 203; DpiY = 203; } // 毫米转DIP public static double MmToDip(double mm) { return mm / 25.4 * 96; } // DIP转打印机物理点 public static double DipToDot(double dip) { return dip * (DpiX / 96.0); } }有了这个工具类,画票据时就可以用毫米来思考,代码里写PrintUnit.MmToDip(5)表示5毫米,直观又不容易错。
4. 票据版式设计:从纸张规格到每一行的定位
票据的版式设计,本质上是把一张固定宽度的纸,从上到下切成若干区域,每个区域放固定内容。热敏小票常见宽度是58毫米和80毫米两种,针式打印机常见的是76毫米(三联单)。宽度定了,高度是动态的——内容多就长,内容少就短。
4.1 纸张规格与打印区域
58毫米热敏纸,实际可打印宽度约48毫米;80毫米热敏纸,可打印宽度约72毫米。这个差异来自打印机滚轴的物理边距。设计版式时,所有内容的宽度不能超过可打印宽度,否则会被裁切。
我一般把票据分成几个固定区域:
- 页眉区:商户名称、门店信息、单据标题,通常居中或左对齐
- 明细区:商品名称、数量、单价、金额,用表格或分列对齐
- 汇总区:合计金额、优惠、实收,字号加大加粗
- 页脚区:时间、单号、二维码、感谢语
每个区域的高度按内容行数动态计算,区域之间留2到3毫米的间距。这样设计的好处是,不管明细有多少行,整体版式不会乱。
4.2 用FormattedText精确控制文字
DrawingContext.DrawText需要FormattedText对象。FormattedText的构造函数里有个pixelsPerDip参数,这个参数就是用来做DPI适配的,很多人忽略它,导致文字模糊或大小不对。
FormattedText ft = new FormattedText( "商品名称", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(new FontFamily("微软雅黑"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), 10, // 字号,单位是磅 Brushes.Black, PrintUnit.DpiX / 96.0 // pixelsPerDip,关键参数 ); dc.DrawText(ft, new Point(x, y));pixelsPerDip传对了,文字在打印机上的实际大小才准确。字号建议用9到12磅,太小了热敏打印机会糊,太大了浪费纸。
4.3 分列对齐的实用技巧
票据明细通常是"名称 数量 单价 金额"四列。用FormattedText画的时候,每列要单独定位。我的做法是预先定义好每列的X坐标和宽度:
double[] colX = { 5, 30, 42, 55 }; // 单位毫米 double[] colW = { 25, 12, 13, 15 };画每一行时,名称列如果超宽就截断加省略号,数字列右对齐。右对齐的计算方式是:x + width - text.Width。这样打出来的票据,数字列整整齐齐,看着专业。
提示:热敏打印机的字宽和屏幕不一样,中文和英文的宽度也不同。建议用等宽字体(如宋体)打明细,对齐效果更好。
5. 条码与二维码:票据上最考验精度的元素
票据上打条码和二维码,是很多人的噩梦。屏幕上好好的条码,打出来扫不出来,原因基本都出在模块宽度和静区上。
5.1 一维条码的模块宽度计算
以Code128为例,条码由若干条和空组成,每个模块的宽度决定了条码的整体宽度。热敏打印机的物理点最小是1/203英寸,约0.125毫米。如果模块宽度小于2个物理点,打印出来就会糊。
假设你要打一个12位的Code128条码,编码后大约有100个模块。如果模块宽度设为0.25毫米,条码总宽就是25毫米,在58毫米纸上放得下。如果模块宽度设为0.15毫米,总宽15毫米,但打印精度不够,扫码枪读不出来。
我的经验值:热敏打印机上,条码模块宽度不要低于0.25毫米,最好在0.3毫米左右。针式打印机精度更低,模块宽度要更大。
用DrawingContext画条码时,不要用DrawImage贴图,而是根据条码编码结果,逐个模块画矩形:
foreach (var bar in code128Bars) { if (bar.IsBar) { double w = bar.Modules * moduleWidth; dc.DrawRectangle(Brushes.Black, null, new Rect(x, y, w, height)); x += w; } else { x += bar.Modules * moduleWidth; } }这样画出来的条码,每个模块都对齐到物理点,精度最高。
5.2 二维码的尺寸与容错
二维码比一维条码好打,因为它对精度要求相对低。但二维码有个"静区"要求,四周要留出至少4个模块宽度的空白,否则扫不出来。很多人打二维码时把静区忘了,贴边打,结果扫半天没反应。
二维码的尺寸建议:在58毫米纸上,二维码边长不要小于15毫米,20毫米左右比较稳妥。容错级别选M或Q,太高了模块变多,反而不好打。
生成二维码可以用现成的库,把结果转成布尔矩阵,然后按模块画矩形。同样不要贴图,贴图会引入缩放误差。
5.3 条码打印后的验证方法
打完条码别急着上线,一定要实测。我的验证流程是:
- 用不同品牌的扫码枪各扫10次,记录成功率
- 把票据放一天后再扫,看热敏纸褪色后还能不能读
- 用手机摄像头扫,模拟普通用户场景
如果成功率低于95%,就得调整模块宽度或条码高度。条码高度建议不低于10毫米,太矮了扫码枪对不准。
6. 打印机通信与纸张处理:那些文档里不写的细节
WPF的打印API封装得比较好,但票据打印机的很多特性,WPF管不了,得靠底层接口。
6.1 走纸与切纸控制
热敏打印机打完一张票,通常要自动走纸一段再切纸。WPF的打印API没有直接控制走纸的命令,得通过打印机的ESC/POS指令集发送控制码。常见做法是:在打印内容末尾追加一段走纸指令,或者打印完成后单独发送切纸命令。
ESC/POS指令通过RawPrinterHelper这类封装,用Win32的WritePrinter直接发给打印机:
// 走纸3行后切纸的ESC/POS指令示例 byte[] cutCmd = new byte[] { 0x1B, 0x64, 0x03, 0x1D, 0x56, 0x00 }; RawPrinterHelper.SendBytesToPrinter(printerName, cutCmd);不同品牌的指令可能略有差异,用之前查一下对应机型的指令手册。
6.2 打印队列的并发问题
收银系统往往有多个收银台共用一台打印机的情况。如果多个线程同时往一个PrintQueue里塞任务,容易出现任务错乱或丢失。我的做法是加一个打印队列管理器,所有打印请求排队串行执行,一次只处理一个任务。
private static readonly object _lock = new object(); public static void PrintReceipt(DrawingVisual visual) { lock (_lock) { PrintDialog pd = new PrintDialog(); pd.PrintVisual(visual, "小票"); } }简单粗暴,但有效。票据打印频率不高,串行完全够用。
6.3 缺纸、离线状态的检测
打印机缺纸或离线时,WPF的打印API会抛异常或者静默失败。为了给用户明确提示,可以查询PrintQueue的状态:
PrintQueue pq = new LocalPrintServer().GetPrintQueue(printerName); if (pq.IsOffline) { /* 提示离线 */ } if (pq.IsPaperOut) { /* 提示缺纸 */ }不过这些属性不是所有驱动都支持,实测下来热敏打印机的支持度参差不齐。更可靠的方式是监听打印任务的失败事件,或者干脆在打印前发一条测试指令探测打印机状态。
7. 踩坑实录:几个让我加班到深夜的票据打印问题
票据打印的坑,很多是"看起来没问题,打出来就是不对"。我挑几个印象最深的说说。
7.1 打印内容偏移:OriginWidth没算对
有一次打出来的票据整体向右偏了3毫米,左边内容被裁掉。查了半天,发现是PageImageableArea.OriginWidth没加到坐标里。热敏打印机的可打印区域不是从(0,0)开始的,而是从(OriginWidth, OriginHeight)开始。所有坐标都得加上这个偏移量。
修复方式很简单,在坐标换算工具类里统一加上Origin:
public static Point Adjust(double xMm, double yMm) { return new Point( MmToDip(xMm) + OriginX, MmToDip(yMm) + OriginY); }7.2 条码扫不出来:模块宽度小于物理点
前面提过,条码模块宽度太小会糊。我遇到过一次,条码在屏幕上看着很清楚,打出来扫码枪死活读不出来。用放大镜看,细条纹已经连成一片了。把模块宽度从0.2毫米调到0.3毫米,问题解决。
这个坑的教训是:条码的模块宽度必须大于打印机的最小物理点,且最好留2倍余量。
7.3 中文乱码:字体没嵌入或编码不对
用FormattedText打中文,偶尔会遇到乱码或方框。原因通常是字体在打印机端不存在,或者pixelsPerDip参数传错导致字形渲染异常。解决办法是显式指定系统自带的中文字体(如宋体、微软雅黑),并确保pixelsPerDip正确。
如果还是乱码,可以退而求其次,把文字转成图片再打印。虽然精度略降,但兼容性最好。
7.4 打印速度慢:每次重新创建PrintDialog
PrintDialog的创建和PrintQueue的获取是有开销的。如果每打一张票都重新new PrintDialog(),在高频打印场景下会明显变慢。优化方式是缓存PrintQueue对象,复用打印会话。
不过要注意,缓存的对象在打印机切换或驱动更新后可能失效,需要做失效重建。
8. 一套可复用的票据打印框架长什么样
把上面的经验串起来,一个可复用的票据打印框架应该包含这几个模块:
- 打印单位换算模块:负责毫米、DIP、物理点之间的转换,统一坐标基准
- 版式绘制模块:基于
DrawingVisual,提供画文字、画线、画条码、画二维码的方法 - 打印机通信模块:封装
PrintDialog、PrintQueue、ESC/POS指令发送 - 队列管理模块:串行化打印请求,处理并发
- 异常处理模块:检测缺纸、离线、打印失败,给出明确提示
这套框架搭好之后,新增一种票据只需要写一个绘制方法,把数据填进去,剩下的交给框架。我后来做过的几个项目,都是直接复用这套结构,改改版式就能上线。
最后分享一个小心得:票据打印一定要用真实打印机测试,模拟器靠不住。我见过太多在开发机上跑得好好的,一到现场就出问题的情况。打印机的物理特性、纸张的厚度、环境的温湿度,都会影响最终效果。有条件的话,备一台和目标机型一致的测试机,能省掉大量现场调试的时间。