1. 为什么票据打印值得单独拎出来做
做过桌面端业务系统的人多半都碰过这个场景:销售开单要出小票、仓库发货要出拣货单、财务对账要出结算凭证、医院挂号要出排队条。这些需求看起来都是“打印”两个字,但真到落地的时候,坑比想象中多得多。WPF 打印票据这件事,表面上是调用PrintDialog或者PrintDocument,实际上牵扯到排版坐标系、打印机 DPI、纸张规格、走纸定位、中文字体渲染、批量打印性能这一整条链路。
我最早接触这类需求是在一个零售收银系统里,当时用的是 58mm 热敏小票机,需求很简单:把订单号、商品明细、金额、时间打出来。结果第一版上线就翻车——预览正常,真机打出来整体偏移了 3 毫米,二维码被切掉一半。后来查了半天才发现是打印机驱动默认的纸张边距和 WPF 的PrintTicket没对齐。这类问题在文档里几乎不会写,只有真正踩过才知道。
所以这篇内容我想聊的不是“WPF 怎么调用打印 API”这种入门问题,而是一个能真正跑在门店、仓库、窗口场景里的票据打印方案该怎么设计。适合谁看?如果你正在做 POS、ERP、WMS、医疗挂号、餐饮点单这类需要出小票的桌面系统,或者你已经被“预览和实际不一致”“批量打印卡顿”“换台打印机就错位”这些问题折磨过,那这篇应该能帮你少走不少弯路。
核心关键词就几个:WPF 打印、票据排版、FixedDocument、FlowDocument、热敏打印机、走纸定位、批量打印。下面我会从整体设计思路开始,一层层拆到具体实现和排查技巧。
2. 票据打印的整体设计思路与方案选型
2.1 先搞清楚你的票据属于哪一类
票据打印不是一个统一问题,按输出形态大致可以分成三类,方案选型完全不一样:
| 票据类型 | 典型场景 | 宽度 | 内容特点 | 推荐方案 |
|---|---|---|---|---|
| 热敏小票 | 收银、点单、排队 | 58mm / 80mm | 窄、长、连续纸 | FixedDocument + 自定义纸张 |
| 针式多联单 | 发货、财务凭证 | 241mm 折叠纸 | 多联复写、表格线 | FlowDocument + 表格 |
| A4 单据 | 对账单、报表 | 210mm | 图文混排、分页 | FlowDocument + 分页控制 |
选型的第一步不是选 API,而是先确认纸张和打印机。很多人一上来就写FlowDocument,结果发现热敏纸是连续走纸的,根本没有“页”的概念,FlowDocument的分页逻辑反而成了累赘。这种情况下FixedDocument或者直接用DrawingVisual自己画反而更可控。
2.2 为什么我最终倾向 FixedDocument 而不是直接调 Win32
WPF 打印有三条主流路线,我把它们的取舍列一下:
- 直接 P/Invoke 调 Win32 打印 API:控制力最强,能精确控制走纸、切纸、开钱箱,但代码量大、可维护性差,而且和 WPF 的视觉树完全脱节,排版要自己算像素。
- PrintDialog + FlowDocument:最省事,适合 A4 文档,但 FlowDocument 的分页、边距、列宽在窄票据上很难精确控制,容易出现“差一点点”的问题。
- PrintDialog + FixedDocument:排版用 WPF 的视觉元素,坐标精确到设备无关单位,配合
PrintTicket能精确控制纸张,是我在票据场景里用得最多的方案。
我选 FixedDocument 的核心理由是:票据的本质是“固定版式的短文档”,而不是“会流动的长文档”。一张小票的每个元素位置基本是确定的,用固定坐标反而比流式布局更稳。而且 FixedDocument 可以直接复用 WPF 的TextBlock、Border、Image,做出来的票据样式和界面预览能保持高度一致,减少“预览一套、打印一套”的维护成本。
2.3 坐标系与 DPI:所有错位的根源
在动手之前必须把这件事想明白:WPF 用的是设备无关单位(1/96 英寸),打印机用的是物理尺寸,两者之间靠 DPI 换算。一张 80mm 宽的纸,换算成 WPF 单位是:
80mm = 80 / 25.4 英寸 ≈ 3.1496 英寸 3.1496 英寸 × 96 ≈ 302.36 DIU也就是说,你的票据根容器宽度应该设成约 302 个单位。如果你按 80 去设,打出来只有实际宽度的四分之一多一点,内容会被挤在左边一小条里。这个换算我见过太多人算错,尤其是从 Web 转过来做桌面的人,习惯性地把毫米当像素用。
提示:所有票据尺寸建议统一用“毫米 → 英寸 → DIU”这条链路换算,不要凭感觉填数字。换算函数封装一次,全项目复用。
3. 核心细节解析与实操要点
3.1 纸张规格怎么定义才靠谱
热敏打印机的纸张规格有两种定义方式:一种是在驱动里选“58mm 系列”这种预设,另一种是自定义PrintTicket的PageMediaSize。我的经验是优先用驱动预设,自定义作为兜底,因为不同品牌驱动对自定义尺寸的支持程度差异很大。
自定义纸张的核心代码大概是这样:
var printTicket = printDialog.PrintTicket; var mediaSize = new PageMediaSize(PageMediaSizeName.Unknown, widthDIU, heightDIU); printTicket.PageMediaSize = mediaSize; printTicket.PageOrientation = PageOrientation.Portrait;这里有个关键点:高度不要设成实际纸张高度。热敏纸是连续纸,你设一个固定高度,打印机就会按这个高度分页,多出来的内容会被切到下一页,导致中间出现空白。正确做法是把高度设成一个“足够大”的值(比如 2000 DIU),让所有内容都在一页里,靠打印机自己的走纸逻辑去切。
3.2 边距与可打印区域
打印机都有物理不可打印边距,热敏机一般是左右各 2mm 左右。如果你把内容顶到最边上,很可能被裁掉。我的做法是在根容器上留一圈安全边距:
var pageContent = new FixedPage { Width = paperWidthDIU, Height = paperHeightDIU, Padding = new Thickness(8, 4, 8, 4) // 约 2mm 左右 };这个 8 不是随便填的,是按 2mm 换算来的:2 / 25.4 × 96 ≈ 7.56,取整到 8。留边距这件事看起来小,但它是“预览正常、打印被切”问题的第一道防线。
3.3 字体选择:别用系统默认
票据打印对字体的要求很特殊:要等宽、要清晰、要小字号下不糊。WPF 默认的字体在热敏机上打出来往往偏细,尤其是小字号时笔画会断。我一般用Consolas或者微软雅黑配合加粗,字号控制在 9 到 11 之间。
还有一个坑:中英文混排时的对齐。如果票据里有“商品名 + 数量 + 金额”这种三列布局,用普通字体很难对齐,因为中文和数字的宽度不一样。解决办法是用等宽字体,或者干脆用Grid分列,让 WPF 自己算对齐。
var text = new TextBlock { FontFamily = new FontFamily("Consolas"), FontSize = 10, FontWeight = FontWeights.Bold, Text = "商品名称 数量 金额" };3.4 二维码和条码的渲染
票据上经常要打二维码或者条码。WPF 本身没有内置条码控件,常见做法是生成图片再贴到Image里。这里有个性能陷阱:如果每张票都重新生成二维码图片,批量打印时会非常慢。我的做法是把二维码生成结果缓存起来,相同内容直接复用。
另外,二维码在热敏纸上的清晰度很依赖打印机的点密度。203dpi 的机器打小尺寸二维码容易糊,建议二维码边长不要小于 15mm,并且用纯黑前景、纯白背景,不要用灰色。
4. 完整实操流程与关键环节实现
4.1 从构建 FixedDocument 开始
一个完整的票据打印流程,我一般拆成四步:构建文档 → 配置打印参数 → 预览 → 提交打印。先看构建文档这部分。
public FixedDocument BuildReceiptDocument(ReceiptData data) { var doc = new FixedDocument(); var page = new FixedPage { Width = PaperWidthDIU, Height = PaperHeightDIU }; var stack = new StackPanel { Margin = new Thickness(8, 4, 8, 4) }; stack.Children.Add(BuildHeader(data)); stack.Children.Add(BuildItemList(data.Items)); stack.Children.Add(BuildFooter(data)); page.Children.Add(stack); var pageContent = new PageContent(); ((IAddChild)pageContent).AddChild(page); doc.Pages.Add(pageContent); return doc; }这里用StackPanel做纵向堆叠,是因为票据内容基本是自上而下的线性结构。如果遇到需要左右分栏的部分(比如“合计”和“实收”并排),再局部换成Grid。
4.2 打印参数的配置与提交
构建好文档之后,配置打印参数并提交:
public void PrintReceipt(FixedDocument doc, string printerName) { var printDialog = new PrintDialog(); printDialog.PrintQueue = new PrintQueue( new LocalPrintServer(), printerName); var ticket = printDialog.PrintTicket; ticket.PageMediaSize = new PageMediaSize( PageMediaSizeName.Unknown, PaperWidthDIU, PaperHeightDIU); ticket.PageOrientation = PageOrientation.Portrait; ticket.CopyCount = 1; printDialog.PrintDocument(doc.DocumentPaginator, "票据打印"); }注意PrintDocument的第二个参数是打印任务名,会显示在打印队列里。建议带上业务标识,比如订单号,方便排查问题时定位。
4.3 批量打印的性能处理
批量打印是票据场景里最容易出问题的地方。我做过一个仓库拣货单的需求,一次要打 200 多张,第一版直接循环调用PrintDocument,结果打印机队列堵死,程序界面卡到无响应。
后来改成两个策略:一是把文档构建和打印提交分离,构建在后台线程做;二是控制提交节奏,不要一次性全塞进队列。
public async Task BatchPrintAsync( IEnumerable<ReceiptData> receipts, string printerName) { var docs = await Task.Run(() => receipts.Select(BuildReceiptDocument).ToList()); foreach (var doc in docs) { PrintReceipt(doc, printerName); await Task.Delay(50); // 给打印队列留缓冲 } }那个Task.Delay(50)看起来有点土,但实测下来比不加稳定很多。具体延迟多少要看打印机型号,一般 30 到 100 毫秒之间。
4.4 预览功能的实现
预览不是必须的,但在调试阶段非常有用。WPF 里可以用DocumentViewer直接展示FixedDocument:
var viewer = new DocumentViewer(); viewer.Document = BuildReceiptDocument(data);不过要注意,DocumentViewer显示的是 WPF 渲染结果,和打印机实际输出可能有细微差异,尤其是字体和边距。所以预览只能作为参考,最终还是要以真机测试为准。
5. 常见问题与排查技巧实录
5.1 打印内容整体偏移
这是最高频的问题。原因通常有三个:纸张尺寸设错、边距没留够、打印机驱动里的纸张设置和代码不一致。排查顺序建议是:先用尺子量实际打印出来的偏移量,再反推是哪个环节的问题。如果偏移量是固定的,多半是边距问题;如果偏移量随内容长度变化,多半是纸张高度设错导致分页。
5.2 中文显示成方块或乱码
热敏打印机对字体的支持依赖驱动。如果打出来是方块,先确认打印机是否内置中文字库。没有的话,WPF 会把文字渲染成图形再发送,理论上不会乱码。如果还是乱码,检查TextBlock的FontFamily是否用了打印机不支持的字体,换成通用字体试试。
5.3 批量打印时程序卡死
前面提过,核心原因是打印队列被塞满。除了加延迟,还可以考虑用PrintQueue的异步接口,或者干脆把打印任务丢到一个独立的进程里做,避免影响主界面。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 内容被切边 | 边距不足 | 增大根容器 Padding |
| 中间出现空白页 | 纸张高度设太小 | 增大 PageMediaSize 高度 |
| 二维码模糊 | 尺寸太小或点密度低 | 二维码边长 ≥ 15mm |
| 打印速度慢 | 每张重新生成图片 | 缓存二维码/条码 |
| 换打印机就错位 | 驱动纸张设置不同 | 统一用代码指定纸张 |
| 中文变方块 | 字体不支持 | 换通用字体或转图形 |
5.5 几个我踩过的坑
第一个坑是以为预览对了打印就一定对。实际上 WPF 预览用的是屏幕 DPI,打印用的是打印机 DPI,两者在边距和字体渲染上会有差异。我的建议是调试阶段直接打真机,别太依赖预览。
第二个坑是忽略了打印机的走纸校准。有些热敏机用久了会有走纸偏差,打几十张之后位置就飘了。这种情况要在驱动里做校准,或者定期让用户执行一次“走纸对齐”。
第三个坑是在 UI 线程里构建大量文档。构建FixedDocument涉及视觉树的创建,内容多的时候很吃 CPU,放在 UI 线程会卡界面。养成放后台线程的习惯。
6. 一些延伸思考与个人经验
票据打印这个需求,技术难度其实不算高,但它的“坑密度”很高,因为涉及硬件、驱动、操作系统、WPF 渲染四层,任何一层出问题都会表现为“打出来不对”。我个人的经验是:把纸张尺寸、边距、字体这三个变量固定下来,做成配置项,不要散落在代码各处。这样换打印机或者换纸张规格时,只需要改配置,不用满项目找硬编码的数字。
另外,如果你的系统要支持多种票据类型,建议抽象一个IReceiptRenderer接口,把“构建文档”和“提交打印”分开。构建部分可以针对不同票据类型有不同实现,提交部分统一处理打印参数。这样扩展新票据类型时,只需要加一个渲染器,不用动打印逻辑。
最后分享一个小技巧:调试票据打印时,可以先把FixedDocument渲染成 XPS 文件,用 XPS 查看器打开,这样能排除打印机因素,单独看排版对不对。确认排版没问题了,再上真机测打印参数。这个分离排查的思路,帮我省过很多时间。