ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#标签打印工具实战:GDI+设计器与动态条码生成详解

C#标签打印工具实战:GDI+设计器与动态条码生成详解 纯干货直接上代码思路。这篇东西来自我之前做的一个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是矩阵WidthHeight最好而且建议用自动纠错级别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里做命中检测后直接根据当前悬停的拖拽点类型改Cursorprivate 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 ListElementData Elements { get; set; } new ListElementData(); } [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 Dictionarystring, 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 Dictionarystring, 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”的模板能跑通整个链路再加拖拽再加条码。一步一步来比一开始就想着做一个全功能的工具要靠谱得多。
返回列表