这两年我做标签打印模块的时候,最常被问的一句话是:你们那个能拖来拖去、带标尺、换个模板马上就能出条码的设计器,底层到底是怎么画的?很多人以为里面藏了什么高深的东西,其实拆开看就三件事:一块用 GDI+ 画出来的标尺画布,一套描述“标签上有什么”的数据模型,以及一个能把模型翻译成打印机动作的打印引擎。今天就从设计器功能切入,把标尺绘制和拖拽点实现掰开揉碎讲清楚,顺便把自定义标签布局、动态生成条码、打印输出这条链路完整串一遍。希望这篇东西对准备从零做标签打印程序、或者准备重构老打印模块的 .NET 开发者有点帮助。
1. 项目骨架:标签打印程序到底拆成哪几块
1.1 先分清两类需求
标签打印的需求大体分两类。一类是“模板基本固定,只换数据”,比如仓储货位标签、价格标签、资产编号标签,布局就那几个样式,但每天要打几百上千张;另一类是“经常要临时调版”,比如给客户做定制产品签,今天要加个批次号,明天把条码挪到左下角,后天又要加一个二维码。两类需求落到程序上,都需要一个可视化设计器,因为维护模板的人往往是仓管、运营,不是程序员。
我这个程序主要服务前者,同时兼顾后者。核心思路很朴素:设计器里拖出来的每个元素(文本、条码、二维码、矩形)都保存成一份“布局描述文件”,打印的时候根据这份文件重新绘制,再叠加实际数据。这样模板改动不用改一行代码,数据录入的人也完全不碰设计器。
1.2 系统分四层
| 模块 | 职责 | 关键技术 |
|---|---|---|
| 设计器画布 | 所见即所得编辑布局 | GDI+ 自绘 Panel、标尺、拖拽点、鼠标事件 |
| 模板模型 | 保存元素位置、尺寸与样式 | XML 序列化、单位毫米 |
| 渲染器 | 画布与打印共用同一套绘制代码 | GDI+ 绘制文本、条码、图形 |
| 打印引擎 | 把模板映射到物理纸张 | PrintDocument、DPI 换算、偏移校准 |
重点说一下渲染器为什么设计成共享的。早期我写的第一个版本,画布上画一套代码,打印时又重新画一套,结果很快出现“画布上看着没问题,打印出来歪了半个毫米”这种 bug。后来把绘制逻辑统一了:设计器画布只是给同一个渲染函数传不同的 Graphics 对象和缩放比例。这样屏幕预览和打印机输出的偏差只会来自物理设备本身,而不是两套代码之间的差异。
1.3 为什么先讲设计器这部分
设计器是整个程序的“面子”,也是交互最重的地方。标尺给用户提供精确的尺寸参照,拖拽点让布局调整不需要靠键盘输入坐标。想明白“标尺是怎么画出来的”“拖拽点是怎么命中并改尺寸的”,后面的数据模型和打印链路其实都很自然。所以标题怎么写,咱们就怎么来,先啃这两块硬骨头。
2. GDI+标尺绘制的坐标换算与刻度逻辑
2.1 核心坐标换算:毫米、像素与缩放
标签设计里最反直觉的一件事:屏幕上的“像素”根本不是物理单位。同样是 40mm 宽的标签,在 96DPI 屏幕上显示大约是 150 像素,但在 600DPI 打印机上需要 940 像素。所以设计器内部所有逻辑坐标应该以毫米为准,画布渲染时才换算成像素。
实际上标签往往很小,不放大画根本看不清,所以默认缩放一般是 1mm = 10px。这样 40mm 宽的标签画布就是 400px,看着比较舒服。核心换算函数非常简单:
public class DesignerCanvas { // 1毫米对应的屏幕像素数,程序里可以做成Zoom属性 private float _pixelsPerMm = 10f; public float MmToPx(float mm) => mm * _pixelsPerMm; public float PxToMm(float px) => px / _pixelsPerMm; // 画布逻辑尺寸,单位毫米 public float LabelWidthMm { get; set; } = 40f; public float LabelHeightMm { get; set; } = 30f; public int CanvasWidthPx => (int)(MmToPx(LabelWidthMm)); public int CanvasHeightPx => (int)(MmToPx(LabelHeightMm)); }这个换算要贯穿整个程序:拖拽时鼠标位移换算成毫米,保存模板时存毫米,打印时把毫米直接映射到 300DPI 或 600DPI 的像素。凡是出现“屏幕上一个样、打印另一个样”的,十有八九是这里混用了单位。
2.2 横向标尺:从毫米到像素的刻度映射
画横向标尺第一步是规划区域。它占画布上方大约 18px 高的一条窄带,底色用浅灰,底部画一条基线,然后画两种刻度:小刻度 1mm 一格、高 5px,大刻度 10mm 一格、高 10px,大刻度下面写出毫米数字。
绘制顺序上先画大刻度再画小刻度也不会重叠,直接循环:
private void DrawHorizontalRuler(Graphics g) { const int rulerHeight = 18; float pxPerMm = _pixelsPerMm; int canvasW = CanvasWidthPx; using var bgBrush = new SolidBrush(Color.FromArgb(245, 245, 245)); using var rulerPen = new Pen(Color.FromArgb(120, 120, 120)); using var textBrush = new SolidBrush(Color.FromArgb(64, 64, 64)); using var font = new Font("Microsoft YaHei", 7f); g.FillRectangle(bgBrush, 0, 0, canvasW, rulerHeight); g.DrawLine(rulerPen, 0, rulerHeight - 1, canvasW, rulerHeight - 1); // 小刻度:每1mm,逢10mm加高 for (int mm = 0; mm * pxPerMm <= canvasW; mm++) { int x = (int)Math.Round(mm * pxPerMm); int h = mm % 10 == 0 ? 10 : 5; g.DrawLine(rulerPen, x, rulerHeight - h, x, rulerHeight - 1); } // 大刻度上的毫米数字 for (int mm = 10; mm * pxPerMm <= canvasW; mm += 10) { int x = (int)Math.Round(mm * pxPerMm); g.DrawString($"{mm}mm", font, textBrush, x + 2, 1); } }两个容易被忽略的细节。一是 GDI+ 对象必须 Dispose,连续重绘时不释放 Pen、Brush,很快会积累几千个 GDI 对象,程序变卡甚至花屏。上面代码里using var的写法就是要保证每次绘制完自动回收。二是刻度数字从 10 开始画,0mm 的位置正好是标签左边缘,写了反而容易盖住画布边框。
2.3 纵向标尺:旋转绘制与布局
纵向标尺本质是一样的,挨着画布左侧画一条宽 18px 的竖条。刻度数字竖着写比较难看,最简单的办法是把 Graphics 旋转一下再 DrawString,不需要手动处理字符排列:
private void DrawVerticalRuler(Graphics g) { const int rulerWidth = 18; float pxPerMm = _pixelsPerMm; int canvasH = CanvasHeightPx; using var bgBrush = new SolidBrush(Color.FromArgb(245, 245, 245)); using var rulerPen = new Pen(Color.FromArgb(120, 120, 120)); using var textBrush = new SolidBrush(Color.FromArgb(64, 64, 64)); using var font = new Font("Microsoft YaHei", 7f); g.FillRectangle(bgBrush, 0, 0, rulerWidth, canvasH); g.DrawLine(rulerPen, rulerWidth - 1, 0, rulerWidth - 1, canvasH); for (int mm = 0; mm * pxPerMm <= canvasH; mm++) { int y = (int)Math.Round(mm * pxPerMm); int h = mm % 10 == 0 ? 10 : 5; g.DrawLine(rulerPen, rulerWidth - h, y, rulerWidth - 1, y); } // 旋转坐标系后写数字 for (int mm = 10; mm * pxPerMm <= canvasH; mm += 10) { g.Save(); g.TranslateTransform(rulerWidth - 2, mm * pxPerMm + 2); g.RotateTransform(-90f); g.DrawString($"{mm}", font, textBrush, 0, 0); g.Restore(); } }这段代码里最容易犯错的是旋转状态污染。如果不做g.Save()和g.Restore(),旋转会一直保留,后面画布上所有的元素都会跟着歪掉。所以每次写完一个数字就恢复坐标变换,这是 GDI+ 自绘的必修课。
2.4 标尺与缩放联动:什么时候减刻度
把 Zoom 从 50% 放到 500%,如果还逐毫米画刻度,会出现两种情况:要么刻度密密麻麻叠成一团,要么文字全部挤在一起。一个通用策略是根据_pixelsPerMm决定最小刻度步长:
- 每毫米像素数 >= 10:画出 1mm 小刻度、10mm 大刻度
- 每毫米像素数 >= 3:只画 5mm 中刻度、10mm 大刻度
- 否则:只画 10mm 大刻度,小刻度全部省略
我一般抽出一个GetRulerStep()方法,按缩放区间返回最小刻度毫米数。做大标签模板时缩来缩去,标尺始终不会出现密集恐惧画面。
2.5 画布滚动时标尺一起动
如果画布支持滚动(大标签模板是需要的),标尺不跟着滚就会错位。方案是让设计器使用自定义 Panel,在 OnPaint 里先取AutoScrollPosition,绘制标尺时把滚动偏移量加到坐标里。注意 GDI+ 的坐标原点不会自动跟随滚动,必须自己偏移:
var scrollOffset = AutoScrollPosition; g.TranslateTransform(scrollOffset.X, scrollOffset.Y);然后统一绘制画布加标尺。这个方法比把标尺放到独立控件里简单,也避免了滚动同步的时序问题。做滚动画布时,标尺和内容一定要在同一个 Graphics 上下文里画完,分两次画很容易出现半像素错位。
3. 拖拽点交互:手柄命中、尺寸算法与光标反馈
3.1 8个缩放手柄的几何计算
选中某个元素时,需要画出 8 个缩放手柄——左上、上、右上、右、右下、下、左下、左。有一种更经济的选择是只做四角手柄,但边中点的手柄在微调宽度时非常实用,所以我还是保留 8 个。标签编辑场景下元素往往很小,手柄太少会让人抓狂。
手柄的几何计算很简单:拿到元素在画布上的像素矩形,四角取四个角点,四边中点取中心和边线中点。绘制时用白色填充加蓝色 1px 边框,这样在深色背景图片上也能看得很清楚。手柄尺寸设为 7px 比较合适,太小了鼠标不好点,太大了会遮挡元素内容。
public enum ResizeHandle { None, Move, TopLeft, Top, TopRight, Right, BottomRight, Bottom, BottomLeft, Left } public static Rectangle GetHandleRect(RectangleF boundsPx, ResizeHandle handle, int size) { float cx = boundsPx.Left + boundsPx.Width / 2f; float cy = boundsPx.Top + boundsPx.Height / 2f; float hx = handle switch { ResizeHandle.TopLeft or ResizeHandle.BottomLeft or ResizeHandle.Left => boundsPx.Left, ResizeHandle.TopRight or ResizeHandle.BottomRight or ResizeHandle.Right => boundsPx.Right, _ => cx }; float hy = handle switch { ResizeHandle.TopLeft or ResizeHandle.TopRight or ResizeHandle.Top => boundsPx.Top, ResizeHandle.BottomLeft or ResizeHandle.BottomRight or ResizeHandle.Bottom => boundsPx.Bottom, _ => cy }; return new Rectangle((int)hx - size / 2, (int)hy - size / 2, size, size); }3.2 鼠标按下时的命中测试
命中测试要区分两件事:用户是准备拖动元素,还是准备缩放元素。做法是在 MouseDown 里从顶层往下遍历元素(最后一个元素在最上层),先看鼠标坐标落在不在某个手柄矩形里,再看在不在元素矩形里。元素内部没有任何手柄时算 Move。
private ResizeHandle HitTest(Point mousePos) { foreach (var el in SelectedElements) { var boundsPx = el.BoundsPx; foreach (ResizeHandle handle in Enum.GetValues<ResizeHandle>()) { if (handle == ResizeHandle.Move || handle == ResizeHandle.None) continue; if (GetHandleRect(boundsPx, handle, 7).Contains(mousePos)) return handle; } if (boundsPx.Contains(mousePos)) return ResizeHandle.Move; } return ResizeHandle.None; }命中测试的顺序很关键:手柄一定优先于元素内部。如果反过来,拖手柄时会误判成移动元素,导致尺寸怎么都改不动。另外多选元素时,要按选中集合的绘制顺序反着遍历,保证视觉上最上层的元素优先命中。
3.3 拖动移动与缩放的核心算法
MouseDown 记录操作起点_dragStart = mousePos,同时记录被拖动元素在事件开始时的原始矩形_originalBoundsMm。MouseMove 时计算增量,再换算成毫米。移动元素很简单,直接平移:
element.BoundsMm = new RectangleF( _originalBoundsMm.X + PxToMm(dx), _originalBoundsMm.Y + PxToMm(dy), _originalBoundsMm.Width, _originalBoundsMm.Height);缩放要分方向处理。右下角手柄最简单,宽高各加增量;左上角手柄则是 X、Y 同时移动,Width、Height 反向变化。我用一个 switch 处理所有方向:
private RectangleF ApplyResize(RectangleF originalMm, ResizeHandle handle, float dxMm, float dyMm) { float x = originalMm.X, y = originalMm.Y; float w = originalMm.Width, h = originalMm.Height; switch (handle) { case ResizeHandle.Right: w += dxMm; break; case ResizeHandle.BottomRight: w += dxMm; h += dyMm; break; case ResizeHandle.Bottom: h += dyMm; break; case ResizeHandle.BottomLeft: x += dxMm; w -= dxMm; h += dyMm; break; case ResizeHandle.Left: x += dxMm; w -= dxMm; break; case ResizeHandle.TopLeft: x += dxMm; y += dyMm; w -= dxMm; h -= dyMm; break; case ResizeHandle.Top: y += dyMm; h -= dyMm; break; case ResizeHandle.TopRight: y += dyMm; w += dxMm; h -= dyMm; break; } // 最小尺寸保护,单位毫米 if (w < 3f) w = 3f; if (h < 3f) h = 3f; return new RectangleF(x, y, w, h); }这里有个隐藏很深的坑:当左上角手柄向右拖过原矩形的右边界时,w 会变成负值,矩形几何直接反转,GDI+ 渲染时可能一片空白。最小尺寸保护能挡掉一部分,但更稳的写法是在 w 算完之后补一个校正:if (w < 0) { x = originalMm.Right; w = -w; }。标签元素尺寸一般不会跨零反转,但写一个通用的几何校正总没坏处。
如果想支持按住 Shift 等比缩放,在算完宽高后按原始宽高比对齐即可。实现量不大,但对“按比例缩放条码”这类操作非常有用。
3.4 光标反馈与边界约束
8 个手柄对应 8 种鼠标光标,Windows 自带的光标类型恰好够用:左右方向用 SizeWE,上下方向用 SizeNS,四角用 SizeNWSE、SizeNESW。MouseMove 时命中哪个手柄就把this.Cursor切成对应光标,否则切回默认箭头。不要在 MouseDown 时才切,用户鼠标还没按下就已经需要光标反馈了,反应慢半拍就会让人觉得程序很钝。
边界约束我一般做两级。第一级,普通元素不能拖出画布,保持最少一部分在可视区域内,避免元素跑丢找不到;第二级,条码元素允许部分出界,因为标签打印里确实会有出血设计,打印裁切时内容一半在外边是正常的。所以约束不是越严越好,要分场景放开。
3.5 对齐辅助线:让拖拽点真正好用
只有拖拽点没有对齐参考,用户拖起来还是没底。我在原型阶段加了个简单的吸附:拖动过程中实时计算元素四边、中心与画布中心以及其它元素四边的距离,差值小于 3px 就吸附过去,并画出红色虚线辅助线。
辅助线判断只比较拖动元素与最近一个元素的上下左右边缘,因为标签模板元素量一般不超过二三十个,性能完全没问题。辅助线不要画太多,否则反而干扰视觉,一块标签上同时显示最多 4 条即可。这个功能做起来不难,但对拖拽体验的提升很直观。
4. 自定义标签布局的数据模型与模板序列化
4.1 为什么元素必须用毫米做单位
设计器画布上所有编辑都用像素,但保存到模板文件里的坐标单位必须用毫米。原因很简单:同一个模板要应付 96DPI 的屏幕、300DPI 的打印机、600DPI 的打印机。像素在不同设备上没有意义,毫米才有。这条原则几乎决定了整体代码不会在打印环节翻车。
我定义的元素基类大概长这样(生产代码里每个属性都要加属性通知,这里先略掉):
public enum LabelElementType { Text, Barcode, QrCode, Shape, Image } public class LabelElement { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public LabelElementType Type { get; set; } public float X { get; set; } // 毫米 public float Y { get; set; } public float Width { get; set; } // 毫米 public float Height { get; set; } public int Rotation { get; set; } // 0/90/180/270 }再往下派生具体的元素类型:
public class TextElement : LabelElement { public string Text { get; set; } public string FontName { get; set; } = "Microsoft YaHei"; public float FontSizePt { get; set; } = 9f; public bool Bold { get; set; } public Color ForeColor { get; set; } = Color.Black; public ContentAlignment Alignment { get; set; } = ContentAlignment.MiddleCenter; } public class BarcodeElement : LabelElement { public string BarcodeFormat { get; set; } = "CODE_128"; public string Value { get; set; } public bool ShowText { get; set; } = true; }这套模型没有把“打印时的数据来源”写死,而是留了占位符替换机制,后面讲条码时再展开。
4.2 模板的XML序列化与反序列化
模板格式我选 XML,原因很简单:客户或同事可以直接打开文件对照坐标,出问题方便排查。属性用 Xml 特性标注之后,序列化代码不到二十行:
[XmlRoot("LabelTemplate")] public class LabelTemplate { [XmlAttribute] public string Name { get; set; } [XmlAttribute] public float WidthMm { get; set; } [XmlAttribute] public float HeightMm { get; set; } [XmlElement("Element")] public List<LabelElement> Elements { get; set; } = new(); public static LabelTemplate Load(string xml) { var serializer = new XmlSerializer(typeof(LabelTemplate)); using var reader = new StringReader(xml); return (LabelTemplate)serializer.Deserialize(reader)!; } public string Save() { var serializer = new XmlSerializer(typeof(LabelTemplate)); using var writer = new StringWriter(); serializer.Serialize(writer, this); return writer.ToString(); } }涉及派生类型时,XmlSerializer 要在基类上声明[XmlInclude(typeof(TextElement))]、[XmlInclude(typeof(BarcodeElement))]这类特性,否则反序列化时遇到具体子类会抛类型不匹配异常。我第一次没加,保存模板后重新打开直接崩,排查半天才想起来是 Include 的问题。
4.3 设计器与数据模型之间的实时同步
设计器画布拿到模板对象后,把每个元素映射成一个可绘制对象。元素被选中、拖动、缩放、改属性,最终都写回 LabelElement 对象。属性面板修改属性后要通知画布刷新,画布刷新再触发重绘。这里我用一个轻量的事件,而不是在每个属性里手动调 Invalidate,否则属性面板一次改三个属性会刷三次。
public class ElementChangedEventArgs : EventArgs { public LabelElement Element { get; init; } } // 画布订阅 _template.ElementChanged += (s, e) => Invalidate();多元素群组对齐功能暂时没做,只在属性面板提供 X/Y/宽/高的数字输入框。用户要精确控制间距时直接输数字,往往比拖拽更快,尤其是做表格类整齐排布的标签时。
4.4 变量标签:为动态条码和动态文本留口子
标签模板里最常见的动态数据是条码内容、批次号、序列号、日期。我不会在模板里写死这些值,而是约定占位符写法:{CODE}、{BATCH}、{DATE:yyyyMMdd}。打印时用一个数据源字典替换。这部分逻辑不复杂,但它是“自定义标签布局”从摆设变成生产力的关键——录入数据的人完全不用碰设计器。
5. 动态生成条码:从ZXing到高DPI渲染
5.1 第三方库选型
C# 生成条码可选库不少,我最终选了 ZXing.Net 的BarcodeWriter,主要看中三点:Code128、Code39、EAN、QR 全覆盖,API 稳定,渲染器可以自定义。直接上生成代码:
using ZXing; using ZXing.Common; using ZXing.Rendering; public static Bitmap GenerateBarcodeImage(string content, string format, int widthPx, int heightPx) { var writer = new BarcodeWriter<Bitmap> { Format = format switch { "QR_CODE" => BarcodeFormat.QR_CODE, "CODE_39" => BarcodeFormat.CODE_39, "EAN_13" => BarcodeFormat.EAN_13, _ => BarcodeFormat.CODE_128 }, Options = new EncodingOptions { Width = widthPx, Height = heightPx, Margin = 2, PureBarcode = format != "QR_CODE" }, Renderer = new BitmapRenderer() }; return writer.Write(content); }容易被忽略的是Margin。它控制条码两侧的空白区,这个空白区在扫码时很关键,太小了扫码枪经常识别失败。我一般按毫米换算成像素设定,实际打印保留 2mm 以上的静区比较稳妥。
5.2 千万别用屏幕DPI生成条码再打印
这是条码打印最容易翻车的地方。设计器画布上显示条码可以用低分辨率位图,但打印时必须重新按打印机 DPI 生成高清位图。如果直接拿屏幕 96DPI 的位图塞给打印机,打印出来的条码边缘是糊的,扫码枪对着扫半天扫不出来。
以 300DPI 为例,生成条码宽度这样算:
int px = (int)(barWidthMm / 25.4 * printerDpiX);其中printerDpiX从打印机设置里拿。实际代码里我做了一个简单位图缓存:模板里每个条码元素在打印时重建一次,设计器画布绘制时用同一个函数但传比较小的像素尺寸,避免拖动时每次都卡顿。
5.3 Code128和QR在标签上的差异
Code128 是线性条码,适合数字加字母的批次号,可以包含 ASCII 字符,但中文不行。QR 适合放长文本、中文、URL。同一个标签经常两个都放:QR 给手机系统读,Code128 给仓储扫码枪读。
ZXing 的 Code128 会自动选择最优字符集(A/B/C 子集),不需要手动干预。如果内容以数字为主,希望条码更紧凑,直接指定 CODE_128 然后让库自动优化即可,它内部会拆出最合适的子集。
5.4 条码文字要不要显示
Code128 下面那行人类可读文字(通常是条码内容本身)ZXing 默认不帮你画,需要自己绘。绘制时注意三个细节:不要用中文字体绘制数字内容、文字宽度不要超过条码宽度、文字和条码之间留 2~3px 间距。
if (barcodeElement.ShowText) { string humanText = barcodeElement.Value; var font = new Font("Consolas", 8f); var textSize = g.MeasureString(humanText, font); float textX = barcodeX + (barWidthPx - textSize.Width) / 2; float textY = barcodeY + barHeightPx + 2; g.DrawString(humanText, font, Brushes.Black, textX, textY); }如果条码内容是动态的,比如序列号每天都变,文字也跟着动态刷。这部分逻辑要放在打印数据替换之后再执行,不能拿模板占位符去生成条码。
5.5 热敏纸的物理限制
热敏打印机打印条码时,条码最细的线条宽度建议不小于 2 个打印点。300DPI 下单个打印点直径约 0.085mm,2 个点约 0.17mm。ZXing 生成的条码模块宽度一般没问题,但如果把标签整体缩得很小,比如 20×10mm 的微型条码,密度一高就糊。这时候宁可缩短 CODE128 内容、或者改用 QR,也别把条码宽度压得太狠。
6. 打印输出链路:毫米、DPI与偏移校准(坑最多的一环)
6.1 PrintDocument的标准流程
很多开发者一上来就直接调 PrintDocument,结果各种偏移、缩放不对。先说标准流程:三个事件各管一件事。BeginPrint 里初始化字体、条码位图等资源;PrintPage 里绘制整个标签页;EndPrint 里释放资源。
实例化时设置纸张规格:
var printDoc = new PrintDocument(); printDoc.PrinterSettings.PrinterName = printerName; printDoc.DefaultPageSettings.PaperSize = new PaperSize( "Custom", (int)(labelWidthMm / 25.4 * 1000), (int)(labelHeightMm / 25.4 * 1000)); printDoc.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); printDoc.PrintPage += OnPrintPage;PaperSize 单位是 1/100 英寸。40×30mm 的标签换算下来大约是 1574×1181。很多初次写的人直接把毫米数字填进去,结果打印出来大得离谱。
6.2 PageUnit与毫米绘制
PrintPage 里我建议把 Graphics 的 PageUnit 设成 Millimeter,然后用毫米坐标画画,这样心理负担最小:
private void OnPrintPage(object? sender, PrintPageEventArgs e) { e.Graphics.PageUnit = GraphicsUnit.Millimeter; e.Graphics.Clear(Color.White); DrawTemplate(e.Graphics, _template, 1f); // 1:1毫米比例绘制 e.HasMorePages = false; }DrawTemplate就是设计器渲染函数,传入不同 Graphics 和缩放比例。PageUnit=Millimeter 之后,_pixelsPerMm不再是 10,而是e.Graphics.DpiX / 25.4,这个值必须由渲染函数从 Graphics 实例上取,不能写死。
6.3 PrinterResolution的获取与伪装
PrinterResolution 字段经常拿不到真实 DPI,某些打印机驱动返回的是“默认质量”而不是具体数值。稳妥做法是打印一页测试图,用尺子量出来校准。代码里我会这样读:
float printerDpiX = 300f; float printerDpiY = 300f; if (printDoc.PrinterSettings.PrinterResolution.X > 0 && printDoc.PrinterSettings.PrinterResolution.Y > 0) { printerDpiX = printDoc.PrinterSettings.PrinterResolution.X; printerDpiY = printDoc.PrinterSettings.PrinterResolution.Y; }如果驱动返回 0 或很低,就退回到程序里可配置的默认 DPI。很多第三方标签打印机驱动会在自定义分辨率里暴露真实值,需要从PrinterSettings.PrinterResolutions里找到合适的项,不能只看第一个。
6.4 偏移校准:每次换打印机都要做功课
标签打印最头疼的是偏移。换一箱标签纸、换一台打印机,甚至同一型号固件版本不同,打印原点都可能差 1~2mm。程序里写一个PrintOffsetX/Y校准设置,按 0.1mm 步进调整。调试时打印一张带十字的测试页,拿尺子量四个方向的偏移量,再填进去。
实测发现偏移主要来自三个地方:打印机机械送纸误差属于物理误差,只能靠偏移参数弥补;驱动对自定义纸张的左右留白处理不一致,有的打印机强制加了几毫米不可控边距;标签卷上黑标或间隙传感器的采样位置不同,换标签纸尤其明显。代码上按硬件像素做偏移换算会精确很多。很多第三方机在驱动里做了原点偏移设置,但那是全局的,会污染别的模板,所以程序里还是保留自己的偏移参数,打印时叠加上去:
e.Graphics.TranslateTransform(offsetXMm, offsetYMm);6.5 自定义预览:比PrintPreviewDialog更靠谱
WinForms 自带的 PrintPreviewDialog 用起来方便,但它渲染用的 DPI 和真实打印用的 DPI 往往不一致,会导致“预览很好,打印偏了”的问题。我最后是自绘预览:用一个 Panel 做屏幕预览,内部把打印机 DPI 换算成屏幕缩放后再渲染打印数据。这样预览和实际打印共用打印参数,视觉上更接近真实效果。
具体做法是预览画布也走 PrintPage 的渲染路径,但 Graphics.PageUnit 设成 Pixel,再乘上一个缩放系数。因为用的是同一套渲染代码,改动量很小,收益却很大。
6.6 打印性能:资源缓存
批量打印大量标签时,每张都重新加载字体和条码位图会非常慢。实测打印 1000 张标签,时间从大约 40 分钟降到 15 分钟,只因为做了三件事:模板解析一次、条码位图按内容缓存、字体对象复用。
位图缓存不能无限膨胀,我加了个简单清理:超过 200 张且上次使用时间超过 30 分钟的就清掉。这样批量打印一整晚也不会内存爆炸。
7. 一些实在的经验
这块补充几条只有自己动手做完完整套才容易踩到的地方,各位可以直接抄作业。
一、画布的 Invalidate 范围一定要控制好。早期图省事,拖动一次就全画布重绘,标签大了之后肉眼可见的卡顿。后来改成Invalidate(selectedElement.Bounds)只刷新选中元素区域,再配合标尺局部重绘,流畅度立刻不一样。GDI+ 性能不是瓶颈,但一个像素级全刷新照样拖垮交互。
二、条码内容一定要有合法性校验。CODE_128 的内容如果带了中文,ZXing 会直接抛异常。打印时遇到异常不要崩掉整个批次任务,我把打印任务设计成“单张失败记录后跳过”,批量跑完告诉你第几张失败。这样仓库现场不会因为一条脏数据卡死整卷标签。
三、模板文件建议带版本号。上线之后发现某个属性字段名改了,旧模板全部无法加载,这种事故很伤。现在模板里固定有一个<Version>字段,加载时做兼容迁移,旧版文件自动升级字段,再也不会出现昨天还能打、今天全报错的情况。
四、别忽略拖拽过程中的 Undo。设计器看起来简单,实际一上来就频繁拖来拖去,误操作肯定有。我在指令模式里包装了 Move/Resize,每次鼠标松开时记录一步,Undo 栈保留 50 步。没有这个功能,光靠“重新拖回去”迟早被用户吐槽到删软件。
做个标签打印程序,绕来绕去其实就一句话:把屏幕编辑的交互、模板的数据结构、打印的物理精度这三件事理清楚,项目就成功了大半。标尺和拖拽点只是入口,后面的打印精度和条码渲染才是真正分高下的地方。希望这些代码和踩坑记录能帮大家少走弯路,下次再有人问你标签设计器怎么实现,就可以直接把这篇文章甩过去了。