纯干货,直接上代码思路。这篇东西来自我之前做的一个C#标签打印工具,不是那种Demo级别的玩具,是真正拿到车间里去打铭牌、打资产标签、打物流面单的东西。核心就三件事:自定义布局设计器、动态条码生成、以及打印输出。我把它拆成几个关键模块来讲,先重点说设计器这块怎么用GDI+画出来,标尺和拖拽点到底怎么实现才顺手。
1. 项目概述与整体设计思路
1.1 这个程序解决什么问题
先说背景。市面上的标签设计软件不少,BarTender、NiceLabel这些都不错,但问题是它们一般绑定特定的打印硬件,或者想要完成“扫描数据进来,实时拼一个标签出来”这种需求,要么靠它的脚本,要么得买高级版。而且很多场景下,你只是要打一个小标签,杀鸡用牛刀。
我自己实际遇到的需求是这样的:产线上每台设备要做资产盘点,需要打一张标签,上面有设备编号、责任人、领用日期,还有一维码和二维码。这个固定格式还好说,麻烦的是“格式经常变”——领导今天说要加上资产分类,明天说要加个“已校准”状态章。如果每次变更都改代码,我就困在需求里出不来了。
所以一个可视化自定义布局的设计器就有了价值。用户能自己拖一拖,设置一下位置和大小,把需要的字段摆上去,然后绑定数据源就能打印。我这篇文章就围绕这个核心来拆解,前半部分重点讲设计器的绘制和交互,后半部分说条码和打印,两边是接在一起的。
1.2 为什么选GDI+而不是WPF或第三方控件
这是当时做技术选型时的一个关键决定。WPF做界面确实漂亮,数据绑定也方便,但有个实际问题——打印这块最终还是要落到GDI+或者PrintDocument上,WPF的打印体系(XPS)在Windows下有一个打印队列转换的额外开销,对小标签这种需要精确定位的场景,反而不如GDI+ Measured和DrawString来得直接。
更重要的是,标签上要摆的东西——文本、条码、矩形框、线条、图片,这些在GDI+里都是非常成熟的绘制操作,不用引入重量级依赖。用第三方控件比如DevExpress的LayoutControl,也不适合做这种从零绘制的场景,因为它们有太多的窗口句柄和布局逻辑,拖拽时的自由度和性能都打折扣。
选择GDI+最核心的逻辑是:设计器本质上是把“纸张”抽象成一个坐标平面,所有元素都在这个平面上绘制和交互。用GDI+画布来做,从设计到打印用的是同一套坐标体系,几乎没有转换成本。这也是为什么很多老牌印刷排版软件到现在内部渲染引擎仍然是GDI+或者类似的立即模式图形接口的原因——它快、可控、不需要额外的UI框架支持。
1.3 功能范围界定:设计器、条码引擎、打印核心
整个项目我按三个模块来拆。
- 设计器模块:负责定义“标签长什么样”。包括纸张尺寸、页边距、背景标尺、元素的添加/选中/拖拽/缩放、属性网格编辑。
- 条码引擎模块:负责把字符串数据生成为可绘制的条码图像。这里我会用BarCode库(后面细说),支持常见的一维码(Code 39、Code 128、EAN-13)和二维码(QR Code)。
- 打印核心模块:负责把设计好的标签模板和运行时数据结合起来,按正确的坐标绘制到打印机。
这三个模块里,设计器是交互最复杂的部分,也是这篇文章花笔墨最多的地方。条码引擎如果你的业务很固定,直接用现成的库封装一层就行,关键在于“动态”两个字;打印核心则有一些坑要注意,后面单开一节讲。
2. 设计器核心机制:坐标系统与绘制原理
2.1 从物理单位到像素单位的换算
只要做打印相关的程序,绕不过去的就是单位换算。屏幕上的1英寸是96像素,但打印机的分辨率可能是300DPI、600DPI,甚至更高。如果你在设计器里硬编码像素值,设计时尺寸和打印尺寸就会不一致,这是新手最容易踩的坑。
我们解决的办法是:定义一种与打印机无关、与屏幕分辨率无关的“文档坐标”,默认以毫米为单位。内部用一个全局静态类来管理换算。
public static class UnitConverter { // 设计器的编辑区我们固定使用 96 DPI 来模拟 private const float ScreenDpi = 96f; private const float MmPerInch = 25.4f; // 毫米转像素(设计器显示用) public static float MmToPixel(float mm) { return mm / MmPerInch * ScreenDpi; } // 像素转毫米(鼠标坐标反算用) public static float PixelToMm(float pixels) { return pixels / ScreenDpi * MmPerInch; } }然后所有控件元素存储的尺寸属性都统一用毫米,只有到了OnPaint的时候,才通过换算画到屏幕的像素坐标上。这里的关键点:
注意:存储用毫米,绘制用像素,交互用像素。三个层面分开,逻辑才不会乱。很多人做设计器一上来就用像素单位,最后打印出来不是放大就是缩小,调起来非常痛苦。
2.2 双缓冲绘制:避免闪烁的核心手段
GDI+绘图最容易出现的问题就是闪烁。这是因为WinForms的控件默认在每次OnPaint前都会用背景色擦除客户区,如果你的Paint事件里画的内容比较多,一擦一画,视觉上就闪个不停,拖拽的时候更是灾难。
处理方式最直接有效的是设置双缓冲。有两种做法,一种是对UserControl设置DoubleBuffered = true,这个属性默认是protected,需要你继承一个类或者通过反射去设置;另一种更干净的做法是用BufferedGraphicsContext手动管理,在绘制前把所有的图先画到一张内存Bitmap上,再一次性的拷贝到屏幕。
我的实际选择是后者,因为设计器画布内容比较复杂,除了元素本身还有标尺、网格、辅助线,手动双缓冲更方便控制擦除范围。核心代码长这样:
protected override void OnPaint(PaintEventArgs e) { // 手动双缓冲绘制 using (Bitmap buffer = new Bitmap(Width, Height)) { using (Graphics g = Graphics.FromImage(buffer)) { g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.Clear(this.BackColor); DrawRuler(g); // 绘制标尺 DrawGrid(g); // 绘制网格 DrawElements(g); // 绘制所有标签元素 DrawSelection(g); // 绘制选中框和拖拽点 e.Graphics.DrawImageUnscaled(buffer, 0, 0); } } }这样写的好处是整个画布的重绘是原子的,不会出现边擦边画的中间态,用户感受到的就是干净、流畅的画面刷新。
2.3 标尺绘制:从简单刻度到智能间距
标尺这东西看着不起眼,但真正用得舒服并不容易。最基础的画法是等间距画刻度线,但如果你从1毫米画到100毫米,像素越来越密,最后糊成一片。所以好的标尺应该自适应缩放级别,当缩小的时候自动调整主刻度间距,放大的时候再放出细分刻度。
我的实现逻辑是定“主刻度间隔”,根据当前的缩放倍率选择一个合适的刻度步长,步长始终是1、2、5、10这样进制递增,符合人的阅读习惯。
private float GetRulerStep(float zoom) { float mmPerPixel = 1f / zoom; float targetPixelStep = 80f; // 希望主刻度大概80像素一个 float targetMmStep = targetPixelStep * mmPerPixel; float[] steps = new float[] { 1, 2, 5, 10, 20, 50, 100 }; float selected = steps[0]; for (int i = 0; i < steps.Length; i++) { selected = steps[i]; if (steps[i] >= targetMmStep) break; } return selected; }标尺绘制时分三层:最外层是细密的次刻度,中间是稍长的中刻度,最底部是带数字的主刻度。数字字体我一般用Segoe UI或者微软雅黑,8号字,画在标尺顶端靠内的位置,避免和画布内容重叠。
还有一个小技巧:标尺的基线和刻度线不要用纯黑色,改用#A0A0A0这种浅灰色,因为标尺是辅助参考,如果颜色太重会干扰视线的焦点,让人分不清主次。
3. 自定义标签元素模型设计
3.1 单一基类设计:LabelElement抽象类
所有的标签元素——文本、条码、图片、线条、矩形——都必须继承自同一个基类,这样才能用统一的List<>去管理和绘制。不然你会发现画文本的时候一种处理方式,画条码又是一种,代码写着写着就失控了。
我的抽象基类大概长这样:
public abstract class LabelElement { public string Name { get; set; } // 元素名称 public float X { get; set; } // 左上角X坐标(毫米) public float Y { get; set; } // 左上角Y坐标(毫米) public float Width { get; set; } // 宽度(毫米) public float Height { get; set; } // 高度(毫米) public bool IsSelected { get; set; } // 选中状态 public int ZOrder { get; set; } // 层级顺序 // 判断点是否命中元素(用于鼠标点击选中) public abstract bool HitTest(PointF point); // 绘制到画布 public abstract void Draw(Graphics g, float zoom); // 返回属性网格要显示的属性列表 public abstract PropertyDescriptorCollection GetProperties(); }这里面HitTest是个容易搞错的点。刚开始可能觉得用Rectangle.Contains判断就够了,但用户点击的时候往往会点到边缘附近3~4个像素的范围内,手感很差。我后来在HitTest里加了一个“容差”的概念,允许用户点击元素边缘一定范围内都算命中。
public override bool HitTest(PointF point) { float tolerance = 3f; // 3毫米容差,约等于12个像素 RectangleF rect = new RectangleF(X - tolerance, Y - tolerance, Width + 2 * tolerance, Height + 2 * tolerance); return rect.Contains(point); }这样用户在快速操作时不需要把鼠标精确地戳到元素正中间,体验会友好很多。
3.2 文本元素:多行支持与字体单位
文本是标签里最基础也最常用的元素。在设计器里,文本元素至少得支持多行,所以不能简单用Graphics.DrawString画单行字符串。我的做法是先用Graphics.MeasureString按当前宽度算出需要换行的高度,再逐行绘制。
这里有个重要细节:字段里可能含有换行符\r\n,也可能因为宽度不够被折行。DrawString配合RectangleF参数会自动处理换行,但你需要提前知道“我设定的高度够不够显示所有内容”,不够的话要有一个溢出提示,在设计器里用红色边框警示用户,否则打印出来就裁掉了。
字体的单位也要注意。字体大小有Point和Pixel的区别,Windows上通常用Point表示,1 Point = 1/72英寸,在96DPI屏幕下1 Point = 1.333像素。所以读取属性的时候要记得做换算,不能用像素值直接填new Font(family, size)。
public static Font CreateFont(string fontFamily, float pointSize, FontStyle style) { // 屏幕画的时候,字体单位转换为像素 float pixelSize = pointSize * 96f / 72f; return new Font(fontFamily, pixelSize, style, GraphicsUnit.Pixel); }打印的时候呢?PrintDocument的Graphics默认是100DPI?不对,它默认是按打印机分辨率来的。如果继续用Pixel单位你算不清的,需要把Graphics.PageUnit改成一个固定的单位,我一般统一改成Millimeter。
e.Graphics.PageUnit = GraphicsUnit.Millimeter;这样你之前存的X、Y、Width、Height直接就能用了,不用再做第二遍换算。
3.3 条码元素:引擎封装与数据绑定
标签里的条码是动态数据,所以条码元素在设计器里看到的是一个“示例数据”渲染出来的图,运行时才替换成真实数据。这个思路和报表工具是一样的。
条码生成我用的是BarCode库,GitHub上比较活跃,支持Code 39、Code 128、EAN-13、QR等常见类型,API也很简洁。封装一层条码接口,防止以后换库导致大面积改代码。
public interface IBarcodeRenderer { Image Render(string content, int width, int height); } public class Code128Renderer : IBarcodeRenderer { public Image Render(string content, int width, int height) { using (var barcode = new BarCode()) { barcode.Symbology = Symbology.Code128; barcode.Code = content; barcode.BarWidth = 2; // 条宽(像素) barcode.BarHeight = height - 10; // 留出下方文字的位置 barcode.ForeColor = Color.Black; barcode.BackColor = Color.White; return barcode.CreateImage(); } } }这里有几个经验:
- 一维码下方一般会带可读字符,所以条码区域高度要预留出文字的空间,别把条码拉满整个元素高度。
- 条码宽度不是任意数字都能用,Code 128会根据内容自动变长,但最小宽度和模块宽有关。所以在生成条码后要检查实际宽度是否超出元素区域,超出了就自动缩小BarWidth来适配。这个逻辑必须做,否则条码变形扫不出来。
- 二维码没有这个问题,QR Code是矩阵,Width=Height最好,而且建议用自动纠错级别M或H,打印时沾上脏东西也不至于扫不出来。
3.4 属性网格绑定:让属性修改即时生效
标签设计器还需要一个属性面板,让用户改字体、改颜色、改坐标。最省事的方式是用WinForms的PropertyGrid控件,直接绑定选中元素的对象。但问题是我们的元素类为了内部逻辑用了各种单位,直接在PropertyGrid里裸露出来不友好。
解决方案是定义一组“可编辑属性”的代理类。当用户选中元素时动态创建一个代理实例,把内部字段映射到友好的属性名上,修改后再回写到元素。
public class TextElementProperties { private TextElement _element; public TextElementProperties(TextElement element) { _element = element; } [Category("布局"), DisplayName("左边距(毫米)")] public float X { get => _element.X; set { _element.X = value; } } [Category("外观"), DisplayName("字体大小(pt)")] public float FontSize { get => _element.FontSize; set { _element.FontSize = value; } } }PropertyGrid绑定这个代理后,修改属性就自动改到元素内部了。这里有个小坑:PropertyGrid默认不会自动刷新显示,需要在属性修改事件里对propertyGrid执行Refresh(),否则改了数字界面不更新,非常容易让人困惑。
4. 拖拽点与交互逻辑实现
4.1 八个拖拽点:位置计算与命中判定
标签元素被选中后,需要显示一个带八个拖拽点的选择框。这八个点分别在四条边的中间和四个角上。为什么要做成八个而不是四个角?因为用户需要上下左右拉伸改变高度或宽度,只做四个角就会很不方便。
每个拖拽点用一个枚举来标识:
public enum DragHandleType { TopLeft, TopMiddle, TopRight, MiddleLeft, MiddleRight, BottomLeft, BottomMiddle, BottomRight }拖拽点的位置计算其实就是根据元素的矩形区域,先算出左上角坐标(在屏幕像素坐标系下)和宽高。
private PointF GetHandlePosition(DragHandleType type) { RectangleF rect = GetScreenRect(); float cx = rect.Left + rect.Width / 2; float cy = rect.Top + rect.Height / 2; switch (type) { case DragHandleType.TopLeft: return new PointF(rect.Left, rect.Top); case DragHandleType.TopMiddle: return new PointF(cx, rect.Top); case DragHandleType.TopRight: return new PointF(rect.Right, rect.Top); // ... 其余类似 } }鼠标命中判断时,遍历八个点的坐标,每个点给一个6像素的命中半径,判断鼠标是否点中。如果都点了,再判断是否点在元素内部,是的话就走移动逻辑,否则就是空白区域,取消选中。
4.2 鼠标交互状态机:完美处理拖拽不卡顿
鼠标交互这块,代码写不好会出现一个常见问题——拖拽的时候元素跟着鼠标抖动或者拖动不平滑。原因是你在MouseMove里每次都重新从鼠标位置计算元素新位置,但鼠标按下时的偏移量没有处理。
我的做法是维护一个简单的状态机:
private DragState _dragState = DragState.None; private PointF _dragStartPoint; private DragHandleType _activeHandle; // MouseDown private void OnMouseDown(object sender, MouseEventArgs e) { PointF docPoint = ScreenToDoc(e.Location); // 优先判断是否点中拖拽点 DragHandleType handle; if (HitTestDragHandle(e.Location, out handle)) { _dragState = DragState.Resizing; _activeHandle = handle; } else if (HitTestElement(docPoint, out _selectedElement)) { _dragState = DragState.Moving; } } // MouseMove private void OnMouseMove(object sender, MouseEventArgs e) { if (_dragState == DragState.Resizing) { ResizeElement(_selectedElement, _activeHandle, e.Location); } else if (_dragState == DragState.Moving) { MoveElement(_selectedElement, e.Location); } }这里的核心是:MouseMove里不要用e.Location - MouseDown时的Location这种增量计算方式,因为你从MouseDown到MouseMove中间可能有别的窗体弹出来抢焦点,导致偏移量错误。更稳妥的是记录MouseDown时“鼠标在元素内的相对偏移”,然后移动时用当前鼠标位置减去这个偏移。
4.3 四象限缩放算法:保持对角线顶点不动
拖拽缩放时,最自然的体验是“拖拽点对面的那个点固定不动”。比如你拖右下角,左上角应该保持原地不动;你拖左边中间的点,右边中间的点固定不动。如果这点做不好,用户拖拽时会感觉元素在跳。
算法其实不复杂:拿到当前鼠标位置在文档坐标系下的坐标,根据拖拽点类型决定新的X、Y、Width、Height。
private void ResizeElement(LabelElement element, DragHandleType handle, PointF mouseDoc) { float oldRight = element.X + element.Width; float oldBottom = element.Y + element.Height; switch (handle) { case DragHandleType.BottomRight: element.Width = Math.Max(MinWidth, mouseDoc.X - element.X); element.Height = Math.Max(MinHeight, mouseDoc.Y - element.Y); break; case DragHandleType.TopLeft: float newRight = oldRight; float newBottom = oldBottom; element.X = Math.Min(mouseDoc.X, newRight - MinWidth); element.Y = Math.Min(mouseDoc.Y, newBottom - MinHeight); element.Width = newRight - element.X; element.Height = newBottom - element.Y; break; // 其他方向同理 } Invalidate(); }这里有个容易忽略的点:最小宽高限制。如果你不加限制,用户可以拖拽成负宽度的元素,后面序列化保存、打印都会出问题。我给文本元素的最小宽度设的是5mm,高度是3mm,条码元素最小宽度是15mm,因为太窄了条码根本画不出来。
4.4 光标样式切换:提升操控感的细节
光标切换是很多设计器的痛点。好的设计器,鼠标移到不同拖拽点上的时候,光标应该自动变成对应的箭头形态——左上角是SizeNWSE,上面中间是SizeNS,右侧是SizeWE。
在MouseMove里做命中检测后,直接根据当前悬停的拖拽点类型改Cursor:
private void UpdateCursor(Point mousePos) { DragHandleType handle; if (HitTestDragHandle(mousePos, out handle)) { switch (handle) { case DragHandleType.TopLeft: case DragHandleType.BottomRight: Cursor = Cursors.SizeNWSE; break; case DragHandleType.TopRight: case DragHandleType.BottomLeft: Cursor = Cursors.SizeNESW; break; case DragHandleType.TopMiddle: case DragHandleType.BottomMiddle: Cursor = Cursors.SizeNS; break; case DragHandleType.MiddleLeft: case DragHandleType.MiddleRight: Cursor = Cursors.SizeWE; break; } } else { Cursor = Cursors.Default; } }这个小功能看着无关紧要,但实际上能直接影响用户对你软件专业度的判断。没有光标切换,用户只能靠“猜”来确定自己能不能拖哪个方向。
5. 自定义布局的序列化与文件存储
5.1 XML序列化:简单可靠的存储方案
设计好的标签模板一定要能保存、能重开、能换台电脑继续改。存储格式我推荐XML,不用折腾数据库,也方便版本管理和人工调试。
标准的做法是定义一个序列化用的DTO,把LabelElement实体转换成可以XML序列化的简单对象:
[Serializable] public class LabelTemplate { public float PaperWidth { get; set; } // 纸张宽(毫米) public float PaperHeight { get; set; } // 纸张高(毫米) public string PrinterName { get; set; } // 打印机名称 public List<ElementData> Elements { get; set; } = new List<ElementData>(); } [Serializable] public class ElementData { public string ElementType { get; set; } // "Text", "Barcode", "Image" public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } public Dictionary<string, string> Properties { get; set; } }Properties这个字典用来存各个元素的特色属性,比如文本的内容、字体、大小,条码的类型、数据源绑定字段等等。
5.2 反序列化与元素工厂
读XML进来的时候,根据ElementType字符串创建对应的派生类实例,然后逐个恢复属性。这里用工厂模式很合适:
public static LabelElement CreateElement(ElementData data) { LabelElement element = null; switch (data.ElementType) { case "Text": element = new TextElement(); break; case "Barcode": element = new BarcodeElement(); break; case "Image": element = new ImageElement(); break; } element.X = data.X; element.Y = data.Y; element.Width = data.Width; element.Height = data.Height; element.LoadProperties(data.Properties); return element; }这里有一个比较隐蔽的问题:当你修改了模板文件的XML结构,老版本的模板打不开了怎么办?我的经验是在加载XML时做容错处理,比如某个属性缺失就给一个合理默认值,不要因为一个标签位读不出来就把整个文件拒之门外。用户宁可看到一个“有点偏差的模板”自己再去调,也好过数据全部丢失。
5.3 模板版本号:为以后扩展留一条路
模板文件里一定要有一个Version字段,这个很多人忽略。
你今天是简单文本加上一个条码,半年后可能这个格式就变成了三个条码区域还包括一个可变图片,到时候你的ElementData结构肯定要加字段。如果没有版本号,老文件被新程序读出来可能格式错乱;有版本号,你可以在加载的时候做字段迁移,老版本数据自动补新字段的默认值。
[Serializable] public class LabelTemplate { public int Version { get; set; } = 1; }最后保存的时候顺手做一个自动备份,就是保存.xml的时候同时把上一版复制成一个.bak文件。日常使用中用户经常干出“改了一下保存后想要回退”的操作,这个备份机制能帮你省下无数求助电话。
6. 动态生成条码的关键实现
6.1 数据绑定:数据源怎么和布局元素关联
动态生成条码,核心是“动态”两个字。场景是这样的:设计器里放的条码元素是一个占位符,用户指定它的数据来源,可能是数据库字段,可能是一个外部传入的序列号,也可能是Excel里的某一列。打印的时候才把真实值填进去。
所以我在BarcodeElement上加一个属性DataSourceName,标识它绑定的是外部数据源的哪个字段。打印时,程序会收到一行数据记录(键值对字典),通过这个字典动态替换每个元素的内容。
public class PrintContext { public Dictionary<string, string> FieldValues { get; set; } public string Resolve(string expression) { if (expression != null && expression.StartsWith("{") && expression.EndsWith("}")) { string field = expression.Trim('{', '}'); return FieldValues.ContainsKey(field) ? FieldValues[field] : ""; } return expression; } }文本元素的Content支持模板语法,比如"设备编号:{DeviceNo}",打印时解析花括号内的字段名,替换成当前记录的值。甚至可以支持拼接多个字段用同一个文本元素显示。这样在不改模板的前提下,一套标签模板能用于几百种不同标签,这就是动态布局灵活性带来的价值。
6.2 批量打印与序列号递增
现场打印还有一类常见需求:打印连续标签。比如打50张流水号,从A001到A050,每张数字加一。这个逻辑不要放在条码生成里,而是放在数据源这一层,作为一条“虚拟字段生成器”。
public class SerialNumberProvider { private string _prefix; private int _start; private int _step; private int _current; public string Next() { int value = _current; _current += _step; return $"{_prefix}{value:D3}"; } }像{SerialNumber}这种特殊字段,在PrintContext里默认注册一个,设置好前綴和起始号后,打印循环里每次调用Next()就可以。这种需求和业务强绑定,放在独立的数据源接口里做扩展比较合适,不要为了图省事把递增逻辑直接写死在条码元素里。
6.3 条码内容合法性校验
条码不是任何字符串都能打。Code 128还好,ASCII范围内的大小写字母数字都能编码;但EAN-13只接受12位数字加1位校验位,你不能拿一个中文公司名去生成EAN-13。所以条码生成前必须做校验,校验不通过给用户清楚的提示。
public static bool ValidateBarcodeContent(Symbology symbology, string content) { switch (symbology) { case Symbology.EAN13: if (!Regex.IsMatch(content, "^\\d{12}$")) return false; break; case Symbology.Code39: if (!Regex.IsMatch(content, "^[0-9A-Z\\-. $/+%]+$")) return false; break; case Symbology.QRCode: if (string.IsNullOrEmpty(content) || content.Length > 4000) return false; break; } return true; }这个校验最大的价值不是拦住程序出错,而是帮用户快速定位问题:一个新手操作员很容易把中文输进Code 128里,结果打出来的条码根本扫不出来。在界面上直接提示“只能输入数字和字母”,比事后排查到底哪一步错了要省心得多。
6.4 条码清晰度优化:在屏幕看似清晰,打印出来模糊
条码模糊是最常见、最影响使用体验的问题。根源往往是分辨率不够。屏幕96DPI下看着很细腻的一个小条码,转到300DPI激光打印机时,条码的最小单位“模块宽度”如果小于打印机的最小点径,打出来就会糊。
我的经验是打印条码时不能用屏幕渲染出来的那个Bitmap直接DrawImage,而是要在打印的时候重新按打印机分辨率生成一次。
private void DrawBarcodeForPrint(PrintPageEventArgs e, BarcodeElement element, PrintContext context) { string content = context.Resolve(element.BindingExpression); // 打印时重新生成,分辨率按打印机的DPI来 int renderDpi = Math.Max(e.Graphics.DpiX, 300); using (Bitmap bmp = RenderBarcodeHighRes(content, element, renderDpi)) { e.Graphics.DrawImage(bmp, element.X, element.Y, element.Width, element.Height); } }这里一个细节:DrawImage还涉及插值模式的问题。如果条码原图的分辨率和目标区域缩放的倍数不成整数比,GDI+默认的高质量双三次插值反而会引入灰色边缘,导致扫描设备读不出来。最稳妥的方案是把元素宽高取整到和设备像素对齐,并且把InterpolationMode设为NearestNeighbor,宁可边缘锋利一点,也不要柔化。
7. 打印输出与常见问题排查
7.1 PrintDocument的完整使用流程
打印输出用System.Drawing.Printing下的PrintDocument。核心流程就是三步:设置打印机、组合PrintPage事件、调用Print。
public void PrintTemplate(LabelTemplate template, PrintContext context) { PrintDocument pd = new PrintDocument(); pd.PrinterSettings.PrinterName = template.PrinterName; pd.DefaultPageSettings.PaperSize = new PaperSize("Custom", (int)(template.PaperWidth / 25.4 * 100), (int)(template.PaperHeight / 25.4 * 100)); pd.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); pd.PrintPage += (sender, e) => { e.Graphics.PageUnit = GraphicsUnit.Millimeter; e.Graphics.Clear(Color.White); foreach (var element in template.Elements) { element.DrawForPrint(e.Graphics, context); } e.HasMorePages = false; }; pd.Print(); }这里有几个值得注意的地方。PaperSize的宽高在PrintDocument里单位不是毫米,而是1/100英寸。所以上面代码里template.PaperWidth / 25.4 * 100是把毫米先转英寸再乘100。默认构造函数里的PaperSize其实没用,你不设置好打印机的纸型,驱动会拿上一次的配置来打,出来的布局和设计稿对不上是必然的。
7.2 边距问题:打印机不支持无边框打印
很多低端打印机并不支持完全无边框打印,物理上它有一个最小边距,比如上下左右各3毫米。所以你的模板设计器里最好能预览“打印机的不可打印区域”,否则你设计的元素如果落在不可打印区,打印出来会被裁切。
实际项目中这个坑很常见。刚开始我在模板里设置了元素离左边2mm,设计器里看起来完全正常,打印出来左边那一列直接消失。排查半天发现是打印机驱动里限制了最小边距。
处理方法有两种:一是读printerSettings支持的物理边距,在画布上把不可打印区域显示成阴影;二是直接把整个画布的四周边距默认设置为3mm,不允许用户把元素拖出安全区域。第二种方式简单粗暴,对操作员更友好,因为大多数标签机的标称边距就是那么大,强行贴边意义不大。
7.3 打印质量低:分辨率与颜色模式调整
打印机属性里有一个打印质量的选项,有些驱动默认是“草稿模式”或者“经济模式”,打印出来的条码颜色深度不够,扫描枪读不出来。如果程序里可以控制的话,打印开始前尝试设置PrinterSettings里的CanDuplex之类的高级属性比较麻烦,但有些驱动可以通过DEVMODE来调整质量。
最省事的方案是:在操作员的培训文档里明确提醒,条码标签请把打印机驱动设置为“标准质量”或“高质量”,不要用草稿模式。如果你发现打出来的条码颜色偏浅,可能是碳带/墨盒浓度不够,也可能真是驱动设置问题,先不要急着改代码。
7.4 常见异常处理与日志
打印这个环节最大的特点是不可控因素多:打印机没开、缺纸、缺墨、驱动崩了、端口被占用……所以打印模块一定要有完善的异常捕获和日志记录。
try { pd.Print(); } catch (InvalidPrinterException ex) { Logger.Error("打印机不可用", ex); MessageBox.Show("打印机连接异常,请检查打印机的电源和数据线。", "打印失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); } catch (Exception ex) { Logger.Error("打印异常", ex); MessageBox.Show("发生未知错误: " + ex.Message, "打印失败", MessageBoxButtons.OK, MessageBoxIcon.Error); }实际现场操作员看到“对象引用未设置为对象的实例”这种黑话会直接崩溃,最好帮他们把错误翻译成人话:没接打印机说“打印机不可用”,没纸说“请检查打印机纸张”,未知错误给出包含日志路径的提示。一个打印程序在车间里能不能立住脚,往往就是这些细节决定的。
8. 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 设计器拖拽元素时闪烁严重 | 未开启双缓冲 / 每次Paint都全量重建字体 | 使用BufferedGraphics或手动Bitmap缓冲绘制 |
| 设计时尺寸和打印输出尺寸不一致 | 存储单位与绘制单位混用 | 统一存储毫米,绘制用像素换算,打印设PageUnit为Millimeter |
| 打印的条码扫描枪读不出来 | 使用了屏幕低分辨率位图 / 缩放产生灰边 | 打印时按打印机DPI重新生成条码,InterpolationMode设为NearestNeighbor |
| 标签左边/上边内容被裁掉 | 打印机不支持无边框打印 | 画布设置安全边距,禁止元素拖出边距 |
| 属性面板修改数值后设计器无变化 | PropertyGrid没有刷新 | 属性修改事件里调用propertyGrid.Refresh() |
| 拖拽缩放时元素跳变 | 增量计算偏移量 | 记录MouseDown时鼠标相对元素偏移,MouseMove时使用绝对定位 |
| 保存的模板下次打开布局错乱 | 版本不兼容 / 缺少默认值容错 | 模板加Version字段,加载属性缺失时给默认值 |
| 连续打印的流水号重复 | SerialNumberProvider状态未在打印循环外保持 | 打印前初始化序号生成器,打印循环内逐次调用Next() |
| EAN-13条码报错 | 内容位数不对 | 打印前校验条码内容合法性,提示具体错误 |
| 导出图片后条码区域空白 | 条码元素实际宽高过小 | 设置条码元素最小宽高,并做绘制检查 |
9. 后续扩展思路
上面讲的这些,其实只是标签打印程序的骨架。真正想做成一个可靠的生产力工具,还可以在这几个方向继续加料:
- 模板版本管理:把模板文件放到共享目录,打标时自动检测最新版本,避免车间里几台电脑模板不一致的问题。
- 批量导入打印:从Excel或CSV批量导入数据,批量打印的同时加入“已打印”标记,防止重复打印。
- 预览窗口:打印前显示渲染后的标签预览,让操作员确认数据无误再按打印键,能省掉很多纸张浪费。
- 多国语言支持:标签程序经常要给国外客户做,把UI字符串资源化,以后扩展语种就方便了。
- 数据库字段映射:界面化配置数据源连接和字段映射,用户自己就能维护打印模板,不用每次都找开发改代码。
说实话,标签打印这个需求听起来不大,但真正做扎实了,里面的细节非常多。从GDI+绘制,到交互设计,到条码渲染清晰度,再到打印机驱动兼容,每一个环节踩过的坑,都能写成一篇文章。如果大家看完这篇文章也想动手做一个,我建议先从最小的功能做起——只是一个打印“Hello World”的模板,能跑通整个链路,再加拖拽,再加条码。一步一步来,比一开始就想着做一个全功能的工具要靠谱得多。