☰
C# GDI+实现标签打印可视化设计器:拖拽、标尺与动态条码全解析
2026/10/14 7:48:45 网站建设 项目流程

做标签打印程序,最容易被吐槽的永远是“改个模板还要改代码”。我这两年用C#写了套支持自定义标签布局和动态生成条码的标签打印程序,核心价值全在那个可视化设计器里,而设计器最底层的鼠标绘制、标尺、选区、拖拽点,全靠GDI+实现。今天把这部分的代码思路拆开讲,适合正在用C#做上位机、对接MES/WMS、做仓库拣货贴或流水线产品标签的同行。读完你能拿走一套可以直接抄的标签设计器雏形。

1. 标签设计器的数据模型:布局自由度的根基

1.1 元素类型与尺寸单位

很多标签程序的写法是把模板写死在代码里,文本框、条码位置全是硬编码,客户改个宽度就要重新编译。真正能自定义布局的设计器,第一步不是画界面,而是先把数据结构定清楚。

我的做法是定义一个TagElement基类,所有标签上的内容都继承它。字段类型初期不用太复杂,够用就行:

public enum ElementType { Text, Barcode, QrCode, Image, Line, Shape } public class TagElement { public ElementType Type { get; set; } public string Name { get; set; } public string Content { get; set; } public RectangleF Bounds { get; set; } // 单位:毫米 public float Rotation { get; set; } // 旋转角度,大多数场景用 0/90/180/270 public bool Locked { get; set; } public bool Visible { get; set; } // 文本专用 public Font Font { get; set; } public StringAlignment Align { get; set; } = StringAlignment.Near; // 条码专用 public BarcodeFormat BarcodeFormat { get; set; } = BarcodeFormat.CODE_128; public bool ShowText { get; set; } = true; // 渲染时解析出来的变量值 public string ResolvedContent { get; set; } }

这里最容易被新手忽略的是Bounds的单位。我强烈建议用毫米,不要用像素。原因很简单:屏幕像素只是预览,最终打印时不同打印机DPI不一样,如果设计器里用像素存位置,换一台200dpi打印机布局就全乱了。用毫米存,无论屏幕显示还是打印输出,都只是一个缩放系数的问题。

配套的文档对象也很简单:

public class TagDocument { public float PaperWidth { get; set; } = 100; public float PaperHeight { get; set; } = 60; public List<TagElement> Elements { get; set; } = new List<TagElement>(); }

这个TagDocument就是整个标签的根。设计器操作的是它,序列化保存的也是它。我习惯用JSON序列化存模板,TagElement里定义好DataContract或者直接用Newtonsoft.Json的属性标注,客户那边改好模板后发个JSON文件回来也能导入,做到模板和程序彻底解耦。

1.2 拖拽点状态机

拖拽点听起来简单,真正写的时候很多人会栽在“鼠标一动元素乱跳”这个问题上。本质上它是一场状态机:当前是空闲、移动元素还是调整大小,当前激活的是哪几个句柄。

我定义了这样一个枚举:

private enum DragMode { None, Moving, Sizing } private enum HandleType { None, Move, TopLeft, TopCenter, TopRight, MiddleLeft, MiddleRight, BottomLeft, BottomCenter, BottomRight, Rotate }

状态切换逻辑集中在三个鼠标事件里:

  • MouseDown:先做命中测试,如果鼠标落在了某个句柄上,进入Sizing模式;如果落在了元素内部,进入Moving模式;否则取消选中。
  • MouseMove:根据当前模式,把鼠标坐标转换成毫米增量,然后修改元素Bounds。
  • MouseUp:清空状态。

关键点是鼠标坐标转换。不要在MouseMove里直接用e.Location的像素差值去改毫米坐标,等窗口缩放后就对不上了。正确做法是先把鼠标像素坐标转换成毫米坐标,再用毫米坐标做增量计算:

private PointF ToCanvasMm(Point mouse) { float pxPerMm = 96f / 25.4f * _zoom; float x = (mouse.X - _origin.X) / pxPerMm; float y = (mouse.Y - _origin.Y) / pxPerMm; return new PointF(x, y); }

然后调整尺寸时,不要一个方向一个方向地硬编码,封装一个通用方法:

private const float MinSize = 2f; // 最小宽高,单位mm public static RectangleF ResizeRect(RectangleF r, HandleType handle, float deltaX, float deltaY) { float left = r.Left, top = r.Top, right = r.Right, bottom = r.Bottom; switch (handle) { case HandleType.Left: left = Math.Min(left + deltaX, right - MinSize); break; case HandleType.Right: right = Math.Max(right + deltaX, left + MinSize); break; case HandleType.Top: top = Math.Min(top + deltaY, bottom - MinSize); break; case HandleType.Bottom: bottom = Math.Max(bottom + deltaY, top + MinSize); break; case HandleType.TopLeft: left = Math.Min(left + deltaX, right - MinSize); top = Math.Min(top + deltaY, bottom - MinSize); break; case HandleType.TopRight: right = Math.Max(right + deltaX, left + MinSize); top = Math.Min(top + deltaY, bottom - MinSize); break; case HandleType.BottomLeft: left = Math.Min(left + deltaX, right - MinSize); bottom = Math.Max(bottom + deltaY, top + MinSize); break; case HandleType.BottomRight: right = Math.Max(right + deltaX, left + MinSize); bottom = Math.Max(bottom + deltaY, top + MinSize); break; } return RectangleF.FromLTRB(left, top, right, bottom); }

这里有个细节:拖Left句柄的时候,只修改left,right不动;拖Right的时候只修改right。这样用户不管怎么拖,元素都不会反转到宽为负。MinSize的限制也很重要,不然条码或文本被缩到0.1mm,打印出来直接没法扫。

2. 用GDI+绘制标尺和网格:刻度换算的细节

2.1 工作区、双缓冲与绘制顺序

设计器的核心控件建议直接继承Control,不要用现成的Panel或者UserControl里面堆坐标计算。自己继承的好处是可以完全接管绘制流程,控制双缓冲。

控件构造函数里先把绘制参数配好:

public class TagDesignerControl : Control { private TagDocument _doc; private float _zoom = 1f; private PointF _origin; // 画布原点,即标尺开始位置 private const int RulerWidth = 25; private const int RulerHeight = 25; public TagDesignerControl() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); BackColor = Color.FromArgb(225, 225, 225); _origin = new PointF(RulerWidth + 5, RulerHeight + 5); } }

OptimizedDoubleBuffer一定要开,不然拖拽元素时整个窗口会疯狂闪烁。ResizeRedraw则是让控件在调整大小时自动重绘,保证缩放后画面立刻更新。

OnPaint里的绘制顺序很讲究,不能乱:

protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; DrawBackground(g); DrawRuler(g); DrawGrid(g); DrawElements(g); DrawSelection(g); }

顺序是:背景 → 标尺 → 网格 → 元素 → 选中框。如果先画元素再画网格,网格线就会盖在条码上面,视觉上很脏。

纸张区域的矩形由文档尺寸和缩放比例决定:

private RectangleF GetPaperRect() { float pxPerMm = 96f / 25.4f * _zoom; return new RectangleF( _origin.X, _origin.Y, _doc.PaperWidth * pxPerMm, _doc.PaperHeight * pxPerMm); }

所有元素绘制时也都基于这个纸张矩形计算位置。纸张以外的区域画成灰色,这样用户一眼就能看出标签的范围。

2.2 刻度间距自适应的计算

标尺不是画一条线就完事。缩放比例变化时,刻度间距要跟着变。如果缩放很小时还画1mm一格的刻度,屏幕上全是数字,完全没法看;如果放大很多时还是固定10mm一格,又显得太粗。

我用的策略是让主刻度在屏幕上始终保持在50到200像素之间:

private void DrawRuler(Graphics g) { float pxPerMm = 96f / 25.4f * _zoom; // 初始主刻度:10mm float majorStep = 10f; while (majorStep * pxPerMm < 50) majorStep *= 2; while (majorStep * pxPerMm > 200) majorStep /= 2; float minorStep = majorStep / 5f; DrawHorizontalRuler(g, pxPerMm, majorStep, minorStep); DrawVerticalRuler(g, pxPerMm, majorStep, minorStep); }

水平标尺的循环要特别注意浮点累计误差。用for (float mm = 0; mm <= width; mm += minorStep)在循环次数多时会越加越偏,所以我改成用索引算坐标:

private void DrawHorizontalRuler(Graphics g, float pxPerMm, float majorStep, float minorStep) { float paperWidth = _doc.PaperWidth; int minorCount = (int)Math.Ceiling(paperWidth / minorStep); using var minorPen = new Pen(Color.FromArgb(180, 180, 180)); using var majorPen = new Pen(Color.FromArgb(80, 80, 80)); for (int i = 0; i <= minorCount; i++) { float mm = i * minorStep; float x = _origin.X + mm * pxPerMm; bool isMajor = Math.Abs(mm / majorStep - Math.Round(mm / majorStep)) < 0.01f; if (isMajor) { g.DrawLine(majorPen, x, 0, x, RulerHeight); if (mm > 0) g.DrawString(mm.ToString("0"), _rulerFont, Brushes.Black, x + 3, 3); } else { g.DrawLine(minorPen, x, RulerHeight - 6, x, RulerHeight); } } }

垂直标尺同理,只是把X和Y互换。这里有个小技巧:主刻度数字从x + 3开始画,不贴在刻度线上,不然数字会盖住刻度线,显得很挤。

网格的绘制不需要每个毫米都画,跟随标尺的主刻度画就够:

private void DrawGrid(Graphics g) { RectangleF paper = GetPaperRect(); using var gridPen = new Pen(Color.FromArgb(220, 230, 240)); float stepPx = 10f * (96f / 25.4f) * _zoom; if (stepPx < 15) stepPx = 20f * (96f / 25.4f) * _zoom; for (float x = paper.Left; x <= paper.Right; x += stepPx) g.DrawLine(gridPen, x, paper.Top, x, paper.Bottom); for (float y = paper.Top; y <= paper.Bottom; y += stepPx) g.DrawLine(gridPen, paper.Left, y, paper.Right, y); }

如果用户开了“吸附网格”,用这个stepPx值做取整就行。

2.3 拖拽点的命中测试与缩放处理

拖拽点的一个关键点:必须保持屏幕上固定大小。我最早实现时直接用毫米算句柄大小,结果缩小到50%时,句柄小到几乎看不清。正确做法是让句柄大小跟缩放系数相关,保证屏幕像素一致:

public static RectangleF[] GetHandleRects(TagElement el, float zoom) { float halfScreen = 3f / zoom; // 句柄屏幕像素的一半,约3px RectangleF r = el.Bounds; RectangleF[] handles = new RectangleF[8]; // 顺序:左上,上中,右上,左,右,左下,下中,右下 handles[0] = new RectangleF(r.Left - halfScreen, r.Top - halfScreen, halfScreen * 2, halfScreen * 2); handles[1] = new RectangleF(r.Left + r.Width / 2 - halfScreen, r.Top - halfScreen, halfScreen * 2, halfScreen * 2); handles[2] = new RectangleF(r.Right - halfScreen, r.Top - halfScreen, halfScreen * 2, halfScreen * 2); handles[3] = new RectangleF(r.Left - halfScreen, r.Top + r.Height / 2 - halfScreen, halfScreen * 2, halfScreen * 2); handles[4] = new RectangleF(r.Right - halfScreen, r.Top + r.Height / 2 - halfScreen, halfScreen * 2, halfScreen * 2); handles[5] = new RectangleF(r.Left - halfScreen, r.Bottom - halfScreen, halfScreen * 2, halfScreen * 2); handles[6] = new RectangleF(r.Left + r.Width / 2 - halfScreen, r.Bottom - halfScreen, halfScreen * 2, halfScreen * 2); handles[7] = new RectangleF(r.Right - halfScreen, r.Bottom - halfScreen, halfScreen * 2, halfScreen * 2); return handles; }

命中测试时,直接把鼠标的毫米坐标和这些句柄矩形做Contains:

private HandleType HitTest(PointF cursorMm) { if (_activeElement == null) return HandleType.None; var handles = GetHandleRects(_activeElement, _zoom); for (int i = 0; i < handles.Length; i++) { if (handles[i].Contains(cursorMm)) { return (HandleType)i + 1; } } // 如果命中了元素内部,返回Move return _activeElement.Bounds.Contains(cursorMm) ? HandleType.Move : HandleType.None; }

绘制选中框时,同样用zoom换算,线宽固定1像素:

private void DrawSelection(Graphics g) { if (_activeElement == null) return; RectangleF paper = GetPaperRect(); RectangleF r = _activeElement.Bounds; float pxPerMm = 96f / 25.4f * _zoom; RectangleF screenRect = new RectangleF( paper.Left + r.X * pxPerMm, paper.Top + r.Y * pxPerMm, r.Width * pxPerMm, r.Height * pxPerMm); using var pen = new Pen(Color.FromArgb(30, 130, 230), 1f); g.DrawRectangle(pen, screenRect.X, screenRect.Y, screenRect.Width, screenRect.Height); RectangleF[] handles = GetHandleRects(_activeElement, _zoom); for (int i = 0; i < handles.Length; i++) { RectangleF h = handles[i]; RectangleF screenHandle = new RectangleF( paper.Left + h.X * pxPerMm, paper.Top + h.Y * pxPerMm, h.Width * pxPerMm, h.Height * pxPerMm); g.FillRectangle(Brushes.White, screenHandle); g.DrawRectangle(pen, screenHandle.X, screenHandle.Y, screenHandle.Width, screenHandle.Height); } }

3. 条码的动态渲染:从变量替换到可扫描输出

3.1 条码生成方案选型

C#里生成条码的库不少,BarcodeLib、ZXing.Net、EssyBarcode都有人用。我最终选型是ZXing.Net,理由是它同时支持一维条码和二维码,而且API简单,社区活跃,输出位图质量也稳定。

安装命令很简单:

dotnet add package ZXing.Net

如果你的项目还是老的Framework,用NuGet界面搜索ZXing.Net也能找到。

BarcodeLib更偏向纯条码,二维码支持弱。EssyBarcode性能不错,但文档少,遇到问题不好查。所以默认方案我推荐ZXing.Net。如果哪天要支持DataMatrix这种工业场景,ZXing也能覆盖。

3.2 先按DPI生成位图再绘制,不要直接拉伸

很多人在设计器里生成条码后,用DrawImage(bmp, rect)直接拉伸到元素矩形。这个操作非常容易让条码扫不出来,因为拉伸会使条码模块宽度变成非整数像素,边缘出现灰度过渡带。打印机再一渲染,整条码就花了。

正确做法是:按目标DPI生成一张精确位图,然后用NearestNeighbor插值绘制,或者干脆在目标DPI下生成后1:1绘制。

我的核心方法长这样:

public Bitmap RenderBarcode(string content, float widthMm, float heightMm, int dpi) { // 目标像素四舍五入,保证DPI精确 int pixelWidth = Math.Max(1, (int)Math.Round(widthMm / 25.4 * dpi)); int pixelHeight = Math.Max(1, (int)Math.Round(heightMm / 25.4 * dpi)); var writer = new BarcodeWriter { Format = BarcodeFormat.CODE_128, Options = new EncodingOptions { Width = pixelWidth, Height = pixelHeight, Margin = 0, PureBarcode = true, } }; return writer.Write(content); }

Margin = 0是为了不要生成额外的左右留白,留白由设计器里的元素Bounds控制。如果你需要条码两侧留固定白边,可以把元素宽度设计成包含留白,或者在生成时给Margin设一个基于DPI的值,但别用默认值让库自己算。

绘制到设计器画布时,用刚刚算出的屏幕缩放系数:

private void DrawBarcodeElement(Graphics g, TagElement el) { float pxPerMm = 96f / 25.4f * _zoom; RectangleF paper = GetPaperRect(); // 设计器预览用96dpi生成即可,太高没必要 using var bmp = RenderBarcode(el.ResolvedContent, el.Bounds.Width, el.Bounds.Height, 96); RectangleF dest = new RectangleF( paper.Left + el.Bounds.X * pxPerMm, paper.Top + el.Bounds.Y * pxPerMm, el.Bounds.Width * pxPerMm, el.Bounds.Height * pxPerMm); g.InterpolationMode = InterpolationMode.NearestNeighbor; g.DrawImage(bmp, dest); }

打印时的区别只是dpi换成打印机实际DPI,其他逻辑一样。

3.3 变量替换与缓存

“动态生成条码”的“动态”主要体现在两个地方:第一,条码内容来自数据库、订单、序列号等运行时数据;第二,每次打印的内容都不同。

所以设计器里放条码元素时,Content字段存的是变量名,而不是实际条码值。比如{SN}、{Date},打印前统一替换:

public void ResolveElements(IReadOnlyDictionary<string, string> data) { foreach (var el in _doc.Elements) { if (el.Type == ElementType.Barcode || el.Type == ElementType.Text) { string key = el.Content.Trim('{', '}'); el.ResolvedContent = data.TryGetValue(key, out var value) ? value : ""; } } }

这样设计器里看到的是占位符,预览和打印时看到的是真实内容,字段绑定清晰,客户也容易理解。

条码生成是相对耗时的操作,批量打印几百个序列号标签时,每次都重新生成位图,性能会很难看。我加了一个简单缓存:

private readonly Dictionary<string, Bitmap> _barcodeCache = new(); public Bitmap GetCachedBarcode(string content, float widthMm, float heightMm, int dpi) { string key = $"{content}|{widthMm:0.00}|{heightMm:0.00}|{dpi}"; if (!_barcodeCache.TryGetValue(key, out var bmp)) { bmp = RenderBarcode(content, widthMm, heightMm, dpi); _barcodeCache[key] = bmp; } return bmp; }

缓存键必须包含内容、尺寸和DPI,否则容易拿到尺寸不对的旧图。打印不同批次时记得清缓存,不然内存会涨得很厉害。

4. 从设计器到打印机:DPI换算与边距实测

4.1 打印DPI换算:为什么PageUnit容易翻车

设计器做完,接下来就是把画好的元素打到标签纸上。这里最大的坑是DPI换算。

GDI+的Graphics.PageUnit可以设成毫米,很多初学者会这样写打印:

e.Graphics.PageUnit = GraphicsUnit.Millimeter;

然后用毫米坐标直接画。看着很美好,但一旦涉及Font就乱了:DrawString里的Font按Point单位解释,不会自动跟着PageUnit走。结果字号忽大忽小,条码文字错位。

我在打印时统一不用PageUnit,而是手动把毫米转成像素:

private void OnPrintPage(object sender, PrintPageEventArgs e) { Graphics g = e.Graphics; // 打印机的物理DPI,不是默认的96 float dpiX = g.DpiX; float dpiY = g.DpiY; foreach (var el in _doc.Elements.Where(x => x.Visible)) { float x = el.Bounds.X / 25.4f * dpiX; float y = el.Bounds.Y / 25.4f * dpiY; float w = el.Bounds.Width / 25.4f * dpiX; float h = el.Bounds.Height / 25.4f * dpiY; if (el.Type == ElementType.Barcode) { using var bmp = GetCachedBarcode(el.ResolvedContent, el.Bounds.Width, el.Bounds.Height, (int)dpiX); g.InterpolationMode = InterpolationMode.NearestNeighbor; g.DrawImage(bmp, x, y, w, h); } else if (el.Type == ElementType.Text) { // Font.Size是pt,转成像素:px = pt * dpi / 72 using var font = new Font(el.Font.FontFamily, el.Font.Size * dpiY / 72f, el.Font.Style); g.DrawString(el.ResolvedContent, font, Brushes.Black, x, y); } } e.HasMorePages = false; }

这里有几个点值得注意:

  • g.DpiX对激光打印机通常是600,对标签打印机可能是203、300、600。如果手动用g.DpiX / 96去缩放屏幕坐标,不如直接用毫米乘DPI来得直白。
  • Font.Size转为像素要除以72,因为1英寸=72pt。比如9pt字号在300dpi下像素 = 9 * 300 / 72 = 37.5px,直接用9去画就会小得离谱。
  • 绘制图片时设置NearestNeighbor,是为了避免GDI+默认的双线性插值让条码边缘模糊。这招对条码识别是救命级别的优化。

4.2 标签纸边距与可打印区:OnPrintPage设置细节

标签打印机打印前需要设置纸张大小。PaperSize构造函数的单位是1/100英寸,不是毫米。比如100mm x 60mm的标签:

PrintDocument pd = new PrintDocument(); pd.DefaultPageSettings.PaperSize = new PaperSize("100x60", 394, 236); // 39.4cm? 其实是1/100英寸

上面这个数字我提醒一下:394和236的单位是1/100英寸,不是像素,所以100mm纸大约是394/100英寸。但很多驱动并不会完全认这个值,最终还是要靠用户在打印对话框里选择标签纸型号。

通用做法是弹打印设置对话框:

PrintDialog dialog = new PrintDialog { Document = pd, AllowSomePages = false, AllowSelection = false }; if (dialog.ShowDialog() == DialogResult.OK) { pd.Print(); }

另外一个坑是边距。Windows打印驱动默认会有1英寸边距,如果不处理,标签内容会被推到一个很小的区域。设置边距为0:

pd.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0);

但是要把话说在前面:即使设置成0,打印机也有硬边距。家用喷墨打印机左右可能各有几毫米无法打印,工业标签机相对好一些。我在设计器里加了一个可配置的“安全边距”提示,用红色虚线框出来,告诉用户别把条码贴到纸边上,不然换一台机器就可能被压掉一截。

4.3 踩过的坑与调试技巧

条码扫不出来的原因排序

我自己遇到条码扫不出来的情况,按概率排列:

  1. 位图被拉伸:设计器预览正常,打印时直接用DrawImage(bmp, destRect)拉伸,模块宽度不均匀。
  2. DPI设置错误:用了96dpi生成位图,然后打印到300dpi打印机上,强行放大3倍。
  3. 条码两侧留白不够:扫描器对条码两端的静区有要求,静区不够会直接失败。

解决办法就是上面说的:按目标DPI生成位图,用NearestNeighbor绘制,并把元素宽度包含静区。

调试工具:在预览里显示像素网格

预览和打印差异很难肉眼判断。我加了一个调试开关,在预览时画一层1px像素网格,用来检查字体是否发虚、条码模块边缘是否齐整。

#if DEBUG void DrawPixelGrid(Graphics g, RectangleF paper) { using var pen = new Pen(Color.FromArgb(40, 255, 0, 0)); for (float x = paper.Left; x < paper.Right; x += 1) g.DrawLine(pen, x, paper.Top, x, paper.Bottom); for (float y = paper.Top; y < paper.Bottom; y += 1) g.DrawLine(pen, paper.Left, y, paper.Right, y); } #endif

这个网格一开,哪个边缘发灰立马能看出来。

字体渲染模式

设计器里可以用ClearTypeGridFit让文字更清晰,但打印时建议换成AntiAliasGridFit或者干脆AntiAlias,因为ClearType是针对LCD屏的亚像素渲染,打到纸上会有彩色边。

打印预览不能完全相信

PrintPreviewDialog本质是用96dpi的屏幕Graphics做渲染,所以它显示的条码清晰度一定比真实打印差。如果预览没问题,但打印有问题,优先考虑DPI换算和位图插值,不要怀疑预览控件。

最后再分享一个小技巧

设计器里的标尺和网格看似不起眼,却是所有拖拽操作的参照系。你把这个参照系的数据结构理清楚,后面加二维码、加图片、加表格都不会乱。先把鼠标坐标到毫米坐标的转换做好,再谈动态条码——这条顺序我踩过的坑不下三次。把你的模板数据模型定义正确,后续的打印、导出PDF、对接数据库都会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询