☰
C#纯原生可视化打印模板设计与所见即所得实现
2026/10/11 3:06:45 网站建设 项目流程

简介:这是一套面向C#/.NET开发者打造的可视化打印模板设计解决方案,适用于需快速定制发票、报告、证书等单据类或标签类打印场景的中高级开发人员。资源提供完整的模板编辑器、图形设计工具与布局管理器,支持拖拽控件、所见即所得预览,并可仅凭Excel数据驱动打印,自动处理单头/明细结构及跨页逻辑,无需第三方依赖,纯原生.NET实现。压缩包共639个文件,含188个运行时DLL、182个配置与序列化XML、70个临时或元数据文件、43个说明文本及41个调试符号PDB,另有CS源码16个、EXE可执行示例3个、Sln/Csproj工程文件各1个,整体89.25MB,结构完整、开箱即用。已有1581人学习下载,读者可直接复用核心设计器模块、集成打印引擎至自有项目,或基于Demo源码快速掌握模板绑定、数据映射与分页渲染全流程。

1. C# 可视化打印模板设计:为什么“拖控件+所见即所得”在产线报表、单据定制场景里不是炫技,而是刚需?

某制造企业产线每天要生成 200+ 种工单、质检卡、装箱单,每种单据字段位置、字体大小、条码区域、公司 Logo 落点都不同;业务部门提需求时只甩来一张手绘草图:“这里加个二维码,那边日期右对齐,红色框线要加粗”。传统做法是让开发改 WinForms 打印逻辑、硬编码坐标、反复编译调试——一次变更平均耗时 3 小时,出错重打浪费纸张不说,还常因 DPI 缩放或打印机驱动差异导致“屏幕上看着对,实际打出来偏移 2mm”。而本方案用纯 .NET 实现的可视化模板编辑器,让非技术人员直接拖拽 Label、TextBox、PictureBox、Barcode(自绘)、Line 等控件,实时预览打印效果,保存为 XML 模板文件,运行时动态加载渲染——不依赖任何第三方商业控件(如 DevExpress、Telerik),不调用 GDI+ 外部封装库,所有绘制逻辑基于 System.Drawing.Common 和 PrintDocument 原生 API 完成。它适合两类人:一是中小项目组缺预算买控件、又不愿被商业授权卡脖子的 .NET 开发者;二是需要快速交付可配置单据系统的集成商。核心价值不在“能拖”,而在“拖完就能打、打出来和屏幕一模一样”。


2. 从零搭建可视化模板编辑器:控件容器、拖拽逻辑与实时渲染三件套

2.1 设计主窗体与可编辑画布:用 Panel 模拟“纸张”,用双缓冲防闪烁

核心思路是:不直接在 Form 上拖控件,而是创建一个继承自Panel的自定义控件TemplateCanvas,它作为所有可拖拽控件的父容器,并承载整个页面坐标系(单位:毫米,1mm = 3.7795px @ 96 DPI)。关键在于启用双缓冲 + 重写OnPaint,避免拖拽时频繁重绘导致撕裂。

public class TemplateCanvas : Panel { public TemplateCanvas() { this.DoubleBuffered = true; // 启用双缓冲 this.ResizeRedraw = true; this.AutoScroll = true; this.BorderStyle = BorderStyle.FixedSingle; this.Size = new Size(827, 1169); // A4 纸默认尺寸(像素,96 DPI 下) this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); // 先绘制背景网格(可选) DrawGrid(e.Graphics); // 再绘制所有已添加的模板控件 foreach (var ctrl in this.Controls.OfType<TemplateControlBase>()) { ctrl.Draw(e.Graphics, this.ClientRectangle); } } private void DrawGrid(Graphics g) { using (var pen = new Pen(Color.LightGray, 0.5f)) { for (int x = 0; x < this.Width; x += 20) // 每20px一条竖线 g.DrawLine(pen, x, 0, x, this.Height); for (int y = 0; y < this.Height; y += 20) // 每20px一条横线 g.DrawLine(pen, 0, y, this.Width, y); } } }

说明:TemplateCanvas不是普通 Panel,它是整个模板的“画布根节点”。DoubleBuffered = true是防闪烁第一道防线;SetStyle中显式开启OptimizedDoubleBuffer是 .NET Framework 下更彻底的双缓冲控制;Size初始化为 A4 尺寸(827×1169 px)是为后续 DPI 适配打基础——所有控件位置/大小均按此物理尺寸映射,而非屏幕像素。DrawGrid仅用于辅助对齐,生产环境可关闭。

2.2 实现可拖拽控件基类:捕获鼠标、计算偏移、限制边界

所有可放入模板的控件(Label、TextBox、Barcode 等)必须继承自TemplateControlBase,它封装了通用拖拽逻辑:按下时记录初始鼠标位置与控件左上角,移动时计算 delta 并更新Location,松开时校验是否越界(不能拖出画布可视区)。

public abstract class TemplateControlBase : Control { private Point _dragStartPoint; private Point _controlStartPoint; private bool _isDragging; protected TemplateControlBase() { this.MouseDown += OnMouseDown; this.MouseMove += OnMouseMove; this.MouseUp += OnMouseUp; this.Resize += OnResize; } private void OnMouseDown(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { _isDragging = true; _dragStartPoint = e.Location; _controlStartPoint = this.Location; } } private void OnMouseMove(object sender, MouseEventArgs e) { if (_isDragging && this.Parent is TemplateCanvas canvas) { var deltaX = e.X - _dragStartPoint.X; var deltaY = e.Y - _dragStartPoint.Y; var newX = _controlStartPoint.X + deltaX; var newY = _controlStartPoint.Y + deltaY; // 限制在画布内(留 5px 边距) newX = Math.Max(5, Math.Min(newX, canvas.ClientSize.Width - this.Width - 5)); newY = Math.Max(5, Math.Min(newY, canvas.ClientSize.Height - this.Height - 5)); this.Location = new Point(newX, newY); canvas.Invalidate(); // 主动触发重绘 } } private void OnMouseUp(object sender, MouseEventArgs e) { _isDragging = false; } private void OnResize(object sender, EventArgs e) { if (this.Parent is TemplateCanvas canvas) { canvas.Invalidate(); } } // 抽象方法:由子类实现具体绘制逻辑 public abstract void Draw(Graphics g, Rectangle canvasBounds); }

参数说明:_dragStartPoint是鼠标按下时的相对坐标,_controlStartPoint是控件原始位置,两者差值即为拖拽位移。canvas.ClientSize是画布当前可视区域(含滚动条),Invalidate()强制刷新画布,确保拖拽过程实时可见。注意:此处未使用DoDragDrop,因为那是 Windows Forms 的“跨控件拖放”,而我们需要的是“画布内自由拖拽”,必须自己管理鼠标事件链。

2.3 构建模板控件集合:Label、TextBox、Barcode 的差异化绘制逻辑

TemplateControlBase是骨架,真正决定“所见即所得”的是各子类的Draw方法。它们必须将自身属性(Text、Font、ForeColor、Bounds)转换为Graphics绘制指令,并严格遵循画布 DPI 设置。

// 示例:文本标签控件 public class TemplateLabel : TemplateControlBase { public string Text { get; set; } = "Label"; public Font Font { get; set; } = new Font("微软雅黑", 10); public Color ForeColor { get; set; } = Color.Black; public override void Draw(Graphics g, Rectangle canvasBounds) { if (string.IsNullOrEmpty(Text)) return; // 关键:使用画布 DPI 缩放字体,保证打印尺寸准确 var dpiScale = GetDpiScale(g); var scaledFont = new Font(Font.FontFamily, Font.Size * dpiScale, Font.Style); var textRect = new Rectangle(this.Location, this.Size); var sf = new StringFormat { Alignment = StringAlignment.Near, LineAlignment = StringAlignment.Center }; using (var brush = new SolidBrush(ForeColor)) { g.DrawString(Text, scaledFont, brush, textRect, sf); } scaledFont.Dispose(); } private float GetDpiScale(Graphics g) { // 获取当前 Graphics 的 DPI 缩放因子(用于高 DPI 显示适配) return g.DpiX / 96f; // 以 96 DPI 为基准 } } // 示例:条码控件(EAN-13,纯 GDI+ 绘制,无第三方依赖) public class TemplateBarcode : TemplateControlBase { public string BarcodeValue { get; set; } = "6901234567890"; public int BarHeight { get; set; } = 50; public int BarWidth { get; set; } = 2; // 条宽(像素) public override void Draw(Graphics g, Rectangle canvasBounds) { if (string.IsNullOrEmpty(BarcodeValue) || BarcodeValue.Length != 13) return; // EAN-13 编码逻辑(简化版,真实项目需完整校验+编码表) var bars = EncodeEan13(BarcodeValue); var startX = this.Location.X; var startY = this.Location.Y; using (var pen = new Pen(Color.Black, BarWidth)) { for (int i = 0; i < bars.Length; i++) { if (bars[i] == '1') // 黑条 { g.DrawLine(pen, startX + i * BarWidth, startY, startX + i * BarWidth, startY + BarHeight); } } } } private string EncodeEan13(string value) { /* 实际编码逻辑,此处省略 */ return ""; } }

关键点:GetDpiScale是“所见即所得”的命脉——它让屏幕显示字体大小与打印输出物理尺寸严格对应。例如在 125% 缩放的显示器上,g.DpiX可能是 120,dpiScale = 120/96 = 1.25,字体自动放大 25%,但打印时PrintDocument使用真实 96 DPI,最终输出仍是 10pt 字体。EncodeEan13是纯算法实现,不调用ZXing或BarcodeLib,完全自主可控。所有控件的Draw方法都只做一件事:把this.Location/Size和属性,通过Graphics绘制到指定矩形内,绝不修改this.Controls或this.Parent——因为它们只是“数据载体”,不是真实 WinForms 控件。


3. 模板持久化与运行时加载:XML 序列化 + 动态实例化控件树

3.1 定义模板数据模型:用可序列化的类结构描述页面与控件

模板不是保存窗体状态,而是保存“页面元数据 + 控件属性快照”。我们设计两个核心类:TemplateDocument描述整页(纸张尺寸、边距、缩放),TemplateControlItem描述每个控件(类型、位置、大小、文本等)。所有属性必须为 public 且可被XmlSerializer序列化。

[Serializable] public class TemplateDocument { public string Name { get; set; } = "New Template"; public float PageWidthMm { get; set; } = 210; // A4 宽 210mm public float PageHeightMm { get; set; } = 297; // A4 高 297mm public float LeftMarginMm { get; set; } = 10; public float TopMarginMm { get; set; } = 10; public float RightMarginMm { get; set; } = 10; public float BottomMarginMm { get; set; } = 10; public List<TemplateControlItem> Items { get; set; } = new List<TemplateControlItem>(); } [Serializable] public class TemplateControlItem { public string Type { get; set; } = "Label"; // "Label", "TextBox", "Barcode" public string Text { get; set; } = ""; public string FontName { get; set; } = "微软雅黑"; public float FontSize { get; set; } = 10; public FontStyle FontStyle { get; set; } = FontStyle.Regular; public string ForeColor { get; set; } = "#FF000000"; // ARGB 十六进制 public int X { get; set; } = 0; // 相对于页面左上角的毫米坐标 public int Y { get; set; } = 0; public int Width { get; set; } = 100; // 毫米 public int Height { get; set; } = 30; public string BarcodeValue { get; set; } = ""; public int BarHeight { get; set; } = 50; public int BarWidth { get; set; } = 2; }

说明:所有坐标/尺寸单位统一为毫米(mm),这是工业级打印的通用单位。ForeColor存为#AARRGGBB字符串,方便跨平台解析;FontStyle是枚举,XmlSerializer可直接处理。Items列表顺序即为绘制顺序(后添加的在上层),无需 ZIndex 属性。

3.2 保存模板:将画布上所有控件转为 TemplateControlItem 并序列化为 XML

保存操作发生在用户点击“保存模板”时。遍历TemplateCanvas.Controls,对每个TemplateControlBase子类,反射提取其公共属性,填充到TemplateControlItem实例中,并转换坐标——关键是从像素坐标转为毫米坐标,依据画布当前 DPI。

private void SaveTemplate(string filePath) { var doc = new TemplateDocument { Name = "Invoice_Template", PageWidthMm = 210, PageHeightMm = 297, LeftMarginMm = 10, TopMarginMm = 10, RightMarginMm = 10, BottomMarginMm = 10 }; foreach (TemplateControlBase ctrl in templateCanvas.Controls.OfType<TemplateControlBase>()) { var item = new TemplateControlItem { Type = ctrl.GetType().Name.Replace("Template", ""), X = PixelToMillimeter(ctrl.Left, templateCanvas), Y = PixelToMillimeter(ctrl.Top, templateCanvas), Width = PixelToMillimeter(ctrl.Width, templateCanvas), Height = PixelToMillimeter(ctrl.Height, templateCanvas) }; // 反射获取控件属性并赋值 var props = ctrl.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (var prop in props) { if (prop.Name == "Text" && prop.PropertyType == typeof(string)) item.Text = (string)prop.GetValue(ctrl); else if (prop.Name == "Font" && prop.PropertyType == typeof(Font)) { var font = (Font)prop.GetValue(ctrl); item.FontName = font.FontFamily.Name; item.FontSize = font.Size; item.FontStyle = font.Style; } else if (prop.Name == "ForeColor" && prop.PropertyType == typeof(Color)) { var color = (Color)prop.GetValue(ctrl); item.ForeColor = $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}"; } else if (prop.Name == "BarcodeValue" && prop.PropertyType == typeof(string)) item.BarcodeValue = (string)prop.GetValue(ctrl); else if (prop.Name == "BarHeight" && prop.PropertyType == typeof(int)) item.BarHeight = (int)prop.GetValue(ctrl); else if (prop.Name == "BarWidth" && prop.PropertyType == typeof(int)) item.BarWidth = (int)prop.GetValue(ctrl); } doc.Items.Add(item); } var serializer = new XmlSerializer(typeof(TemplateDocument)); using (var writer = new StreamWriter(filePath, false, Encoding.UTF8)) { serializer.Serialize(writer, doc); } } private int PixelToMillimeter(int pixel, Control canvas) { // 1mm = 3.7795px @ 96 DPI → pixel / 3.7795 = mm return (int)Math.Round(pixel / 3.7795); }

参数说明:PixelToMillimeter是核心转换函数,它把画布上像素坐标(ctrl.Left/Top)转为物理毫米值,确保保存的模板与设备无关。BindingFlags.Public | BindingFlags.Instance限定只取公有实例属性,避免序列化Site、Parent等 WinForms 内部字段。Encoding.UTF8保证中文字段(如“客户名称”)不乱码。

3.3 加载模板:反序列化 XML,动态创建控件并添加到画布

加载是保存的逆过程。读取 XML 得到TemplateDocument,遍历Items,根据Type字符串反射创建对应控件类型,设置属性,再Add到TemplateCanvas。

private void LoadTemplate(string filePath) { var serializer = new XmlSerializer(typeof(TemplateDocument)); using (var reader = new StreamReader(filePath, Encoding.UTF8)) { var doc = (TemplateDocument)serializer.Deserialize(reader); // 清空画布 templateCanvas.Controls.Clear(); foreach (var item in doc.Items) { TemplateControlBase ctrl = null; switch (item.Type) { case "Label": ctrl = new TemplateLabel(); break; case "TextBox": ctrl = new TemplateTextBox(); break; case "Barcode": ctrl = new TemplateBarcode(); break; default: continue; } if (ctrl != null) { // 设置位置和大小(毫米→像素) ctrl.Location = new Point( MillimeterToPixel(item.X, templateCanvas), MillimeterToPixel(item.Y, templateCanvas) ); ctrl.Size = new Size( MillimeterToPixel(item.Width, templateCanvas), MillimeterToPixel(item.Height, templateCanvas) ); // 反射设置属性 var props = ctrl.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (var prop in props) { if (prop.Name == "Text" && item.Text != null) prop.SetValue(ctrl, item.Text); else if (prop.Name == "Font" && !string.IsNullOrEmpty(item.FontName)) { var font = new Font(item.FontName, item.FontSize, item.FontStyle); prop.SetValue(ctrl, font); } else if (prop.Name == "ForeColor" && !string.IsNullOrEmpty(item.ForeColor)) { var color = ColorTranslator.FromHtml(item.ForeColor); prop.SetValue(ctrl, color); } else if (prop.Name == "BarcodeValue" && !string.IsNullOrEmpty(item.BarcodeValue)) prop.SetValue(ctrl, item.BarcodeValue); else if (prop.Name == "BarHeight") prop.SetValue(ctrl, item.BarHeight); else if (prop.Name == "BarWidth") prop.SetValue(ctrl, item.BarWidth); } templateCanvas.Controls.Add(ctrl); } } } } private int MillimeterToPixel(int mm, Control canvas) { return (int)Math.Round(mm * 3.7795); }

关键点:MillimeterToPixel必须与PixelToMillimeter互为逆运算,否则加载后位置偏移。ColorTranslator.FromHtml是 .NET 原生方法,安全解析#AARRGGBB。注意:此处未使用Activator.CreateInstance,因为已知类型列表有限,switch更高效且可控;若未来扩展控件类型,只需在此处增加case分支。


4. 所见即所得打印:PrintDocument 与 Graphics DPI 对齐的终极校准

4.1 创建打印文档:绑定 Canvas 内容,设置页面尺寸与边距

PrintDocument是 .NET 打印的核心。它的PrintPage事件中,e.Graphics提供的DpiX/DpiY就是目标打印机的真实 DPI(如激光打印机常用 600 DPI),而e.MarginBounds是扣除边距后的可用区域。我们必须让画布渲染逻辑与之完全对齐。

private void PrintTemplate() { var printDoc = new PrintDocument(); printDoc.PrintPage += (sender, e) => { // 关键:用 e.Graphics 的 DPI 计算缩放因子,而非屏幕 DPI var dpiScale = e.Graphics.DpiX / 96f; // 计算打印区域(毫米→像素) var pageWidthPx = (int)(210 * 3.7795 * dpiScale); // A4 宽 210mm → 像素 var pageHeightPx = (int)(297 * 3.7795 * dpiScale); // 创建与打印区域等大的 Bitmap,用于离屏渲染 using (var bmp = new Bitmap(pageWidthPx, pageHeightPx)) { using (var g = Graphics.FromImage(bmp)) { // 设置高质量渲染 g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; g.InterpolationMode = InterpolationMode.HighQualityBicubic; // 绘制背景(白色) g.Clear(Color.White); // 遍历所有控件,按 DPI 缩放后绘制到 Bitmap foreach (TemplateControlBase ctrl in templateCanvas.Controls.OfType<TemplateControlBase>()) { // 将控件位置/大小按 DPI 缩放 var scaledX = (int)(ctrl.Left * dpiScale); var scaledY = (int)(ctrl.Top * dpiScale); var scaledWidth = (int)(ctrl.Width * dpiScale); var scaledHeight = (int)(ctrl.Height * dpiScale); // 创建临时控件副本,设置缩放后的位置大小 var tempCtrl = CreateScaledControl(ctrl, scaledX, scaledY, scaledWidth, scaledHeight, dpiScale); tempCtrl.Draw(g, new Rectangle(0, 0, pageWidthPx, pageHeightPx)); } } // 将 Bitmap 绘制到打印 Graphics(居中) var destRect = new Rectangle( (e.PageBounds.Width - bmp.Width) / 2, (e.PageBounds.Height - bmp.Height) / 2, bmp.Width, bmp.Height ); e.Graphics.DrawImage(bmp, destRect); } }; printDoc.Print(); }

说明:e.Graphics.DpiX是打印机真实 DPI,dpiScale = e.Graphics.DpiX / 96f是缩放倍数。我们不直接在e.Graphics上绘制控件(易受打印机驱动影响),而是先创建高 DPIBitmap,在上面用Graphics.FromImage离屏渲染,最后DrawImage到打印输出——这是最稳定、最可控的“所见即所得”方案。CreateScaledControl是辅助方法,返回一个位置/大小已缩放的新控件实例(不修改原控件)。

4.2 实现缩放控件副本:避免污染原画布,精准匹配打印 DPI

CreateScaledControl必须为每种控件类型创建新实例,并复制所有属性,同时按dpiScale缩放字体、条码宽度等。

private TemplateControlBase CreateScaledControl(TemplateControlBase src, int x, int y, int w, int h, float dpiScale) { TemplateControlBase clone = null; switch (src.GetType().Name) { case "TemplateLabel": var label = new TemplateLabel { Text = ((TemplateLabel)src).Text, ForeColor = ((TemplateLabel)src).ForeColor, Location = new Point(x, y), Size = new Size(w, h) }; // 缩放字体 var origFont = ((TemplateLabel)src).Font; label.Font = new Font(origFont.FontFamily, origFont.Size * dpiScale, origFont.Style); clone = label; break; case "TemplateBarcode": var barcode = new TemplateBarcode { BarcodeValue = ((TemplateBarcode)src).BarcodeValue, BarHeight = (int)(((TemplateBarcode)src).BarHeight * dpiScale), BarWidth = (int)(((TemplateBarcode)src).BarWidth * dpiScale), Location = new Point(x, y), Size = new Size(w, h) }; clone = barcode; break; // 其他类型类似... } return clone; }

参数说明:dpiScale直接作用于Font.Size、BarHeight、BarWidth,确保打印时条码宽度、文字大小与物理尺寸严格对应。Location/Size已在调用前计算好,此处直接赋值。注意:CreateScaledControl返回的是临时对象,用完即弃,绝不Add到任何控件树,避免内存泄漏。

4.3 预览打印效果:用 PrintPreviewDialog 实现真·所见即所得

用户需要确认打印效果再实际出纸。PrintPreviewDialog会自动调用PrintDocument.PrintPage,因此只要PrintPage逻辑正确,预览图就和实际打印一模一样。

private void ShowPrintPreview() { var printDoc = new PrintDocument(); printDoc.PrintPage += (sender, e) => { /* 同 4.1 中的绘制逻辑 */ }; var preview = new PrintPreviewDialog { Document = printDoc, WindowState = FormWindowState.Maximized }; preview.ShowDialog(); }

提示:PrintPreviewDialog是 Windows Forms 原生控件,无需额外引用。它显示的缩略图、翻页、缩放功能全部由系统提供,开发者只需保证PrintPage事件中绘制逻辑正确。这是“所见即所得”最直观的验证方式——预览里看到什么,打印机就打什么。


5. 避坑指南:那些让“所见即所得”变成“所见非所得”的血泪经验

5.1 现象:屏幕上控件位置精准,打印出来整体向右下偏移 5mm

原因:PrintDocument的e.MarginBounds是扣除打印机硬件边距后的区域,而e.PageBounds是整页物理区域。若直接用e.PageBounds作为绘图基准,未考虑e.MarginBounds的起始坐标,会导致内容被“挤”到右下角。
解决:在PrintPage事件中,所有绘制坐标必须相对于e.MarginBounds.Location。例如,若想让控件从页面左上角(含边距)开始绘制,应设destRect.X = e.MarginBounds.X,而非0。修正代码:

// 错误:以 (0,0) 为起点 e.Graphics.DrawImage(bmp, 0, 0); // 正确:以页边距左上角为起点 e.Graphics.DrawImage(bmp, e.MarginBounds.X, e.MarginBounds.Y);

5.2 现象:高 DPI 显示器(如 200% 缩放)下,画布网格线变粗、控件拖拽跳变

原因:Panel默认不支持 DPI 感知,ClientSize返回的是逻辑像素,而Graphics绘制时使用物理像素,导致比例错乱。
解决:在TemplateCanvas构造函数中强制启用 DPI 感知,并重写OnHandleCreated:

protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); if (Environment.OSVersion.Version.Major >= 6) // Windows Vista+ { SetProcessDPIAware(); } } [DllImport("user32.dll")] private static extern bool SetProcessDPIAware();

同时,在OnPaint中,用g.Transform统一缩放:

protected override void OnPaint(PaintEventArgs e) { var scale = this.CreateGraphics().DpiX / 96f; e.Graphics.ResetTransform(); e.Graphics.ScaleTransform(scale, scale); // ... 后续绘制逻辑 }

5.3 现象:条码打印后扫描枪无法识别,但屏幕预览正常

原因:条码宽度BarWidth是像素值,未随 DPI 缩放。在 600 DPI 打印机上,2px 宽度可能只有 0.085mm,低于扫描枪最小识别宽度(通常 ≥0.15mm)。
解决:条码宽度必须按物理毫米定义,而非像素。修改TemplateBarcode类,将BarWidth属性改为float BarWidthMm,默认值设为0.3f(0.3mm),绘制时再转为像素:

public float BarWidthMm { get; set; } = 0.3f; // 物理宽度,单位毫米 public override void Draw(Graphics g, Rectangle canvasBounds) { var barWidthPx = (int)Math.Round(BarWidthMm * 3.7795 * GetDpiScale(g)); // ... 后续绘制使用 barWidthPx }

5.4 现象:加载 XML 模板后,中文文本显示为方块或乱码

原因:XmlSerializer默认使用 UTF-8 编码,但若 XML 文件保存时用了 ANSI 或 GB2312,读取时会解码失败。
解决:强制指定StreamReader编码为 UTF-8,并在保存时写 BOM 头:

// 保存时 using (var writer = new StreamWriter(filePath, false, new UTF8Encoding(true))) // true 表示写 BOM { serializer.Serialize(writer, doc); } // 加载时 using (var reader = new StreamReader(filePath, Encoding.UTF8)) // 显式指定 UTF-8 { var doc = (TemplateDocument)serializer.Deserialize(reader); }

5.5 现象:拖拽控件时,画布滚动条自动跳到顶部,无法拖到页面底部

原因:TemplateCanvas启用了AutoScroll = true,但未设置AutoScrollMinSize,导致滚动范围不足。
解决:在TemplateCanvas的OnSizeChanged中动态设置最小滚动尺寸:

protected override void OnSizeChanged(EventArgs e) { base.OnSizeChanged(e); // 最小滚动尺寸 = 画布内容尺寸(A4)+ 边距 this.AutoScrollMinSize = new Size( (int)(210 * 3.7795) + 20, // A4宽+20px边距 (int)(297 * 3.7795) + 20 // A4高+20px边距 ); }

6. 进阶技巧:用模板变量实现动态数据绑定与条件显示

6.1 定义模板变量语法:在文本控件中嵌入{FieldName}占位符

真正的业务单据不是静态的,而是要填入数据库查询结果。我们在TemplateLabel和TemplateTextBox中支持变量替换:当Text属性包含{OrderNo}、{CustomerName}等格式时,在打印时自动替换为实际值。

// 在 TemplateLabel.Draw 方法中 public override void Draw(Graphics g, Rectangle canvasBounds) { var displayText = Text; if (displayText.Contains("{") && displayText.Contains("}")) { displayText = ReplaceVariables(displayText, DataContext); } // ... 后续用 displayText 绘制 } private string ReplaceVariables(string text, object dataContext) { var result = text; var matches = Regex.Matches(text, @"\{(\w+)\}"); foreach (Match match in matches) { var fieldName = match.Groups[1].Value; var prop = dataContext.GetType().GetProperty(fieldName); if (prop != null) { var value = prop.GetValue(dataContext)?.ToString() ?? ""; result = result.Replace(match.Value, value); } } return result; }

说明:DataContext是一个object类型的公共属性,由使用者在打印前赋值,例如label.DataContext = order;,其中order是一个包含OrderNo、CustomerName等属性的 POCO 类。正则\{(\w+)\}精确匹配{FieldName}格式,避免误替换。

6.2 支持条件显示:用{if:Condition}Content{endif}控制控件可见性

某些字段只在特定条件下显示,如“折扣金额”仅当Discount > 0时出现。我们扩展变量语法,支持简单条件判断。

// 在 TemplateControlBase 中添加 public string VisibilityExpression { get; set; } // 例如 "Discount > 0" // 修改 Draw 方法开头 public override void Draw(Graphics g, Rectangle canvasBounds) { if (!IsVisible()) return; // 先判断是否显示 // ... 后续绘制 } private bool IsVisible() { if (string.IsNullOrEmpty(VisibilityExpression)) return true; try { // 使用 DataTable.Compute 简单计算(仅支持基础表达式) var table = new DataTable(); var row = table.NewRow(); foreach (var prop in DataContext.GetType().GetProperties()) { table.Columns.Add(prop.Name, prop.PropertyType); row[prop.Name] = prop.GetValue(DataContext); } table.Rows.Add(row); var result = table.Compute(VisibilityExpression, ""); return Convert.ToBoolean(result); } catch { return false; // 表达式错误则隐藏 } }

参数说明:DataTable.Compute是 .NET 内置的轻量表达式计算器,支持>,<,==,&&,||等,无需引入NCalc或Jint。VisibilityExpression存为字符串,如"TotalAmount > 1000 && IsVip == true",在Draw时动态求值。注意:此方案适用于简单条件,复杂逻辑建议在数据层预处理。

6.3 打印时传入数据上下文:一行代码完成数据绑定

最终打印调用变得极其简洁。用户只需准备一个数据对象,设置DataContext,调用PrintTemplate即可:

// 准备数据 var invoice = new Invoice { OrderNo = "INV-2023-00 <p> <a href="https://download.csdn.net/download/guo9long/89689575" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询