简介:这是一套面向.NET开发者的C#可视化打印模板设计源码,适合需要为发票、报告、证书、标签及单据等场景定制打印样式的开发者,也支持单独作为打印程序使用。资源提供模板编辑器、图形设计工具与布局管理器等组件,可拖拉控件、所见即所得地编辑模板,仅需提供Excel数据即可调用模板打印,支持标签类单头数据与单据类带表体明细行数据,并自动翻页。压缩包共639个文件,约89.25MB,以dll、xml、txt、pdb、nupkg、cs、png、xlsx等为主,涵盖程序集、配置说明、源码、图标素材与示例数据,另含sln、csproj等工程文件,便于直接编译与二次开发。目前已有1588人学习下载,纯.NET源码、无第三方控件依赖,可自行添加到其他项目中,适合希望快速掌握打印模板设计与集成思路的中高级开发者参考。
1. 从一张退货单说起:为什么我要用纯 .NET 重写可视化打印模板
去年帮一个做仓储管理的小团队看系统,他们的打印模块是硬编码的:每换一个客户,就要改一次代码、重新编译、重新部署。最离谱的一次,客户只是想把出库单上的“收货人”从第二行挪到第三行,前后折腾了两天。这件事让我下定决心,用纯 C# 做一套可视化打印模板设计器——控件可拖拉、所见即所得、不依赖任何第三方控件,并且附带可直接运行的测试 Demo 和完整 .NET 源码。
这套方案要解决的核心问题很明确:让打印模板的调整从“改代码”变成“拖控件”。适合两类人参考:一是正在做进销存、ERP、仓储类课程设计或毕业设计的学生,需要一个能讲清楚架构、又能跑起来的打印模块;二是需要给现有 .NET 系统补一个轻量打印设计能力的开发者,不想引入 DevExpress、FastReport 这类重型商业控件。整套代码基于 .NET 原生 GDI+ 和 WinForms 控件体系构建,没有外部依赖,拿到源码就能编译、就能改。
2. 需求拆解与技术选型:为什么坚持纯 .NET 不用第三方控件
2.1 打印模板设计器的真实功能边界
很多人一听到“可视化打印设计器”,第一反应是“这不就是个报表工具吗”。但报表工具和打印模板设计器是两回事。报表工具侧重数据聚合、分组统计、交叉表;打印模板设计器侧重的是版面编排——把文字、线条、图片、条形码按精确坐标摆到一张纸上,然后套上数据打印出来。
这套方案的功能边界我划了四条线:
第一条,控件拖拉与定位。设计器画布上可以放置文本标签、线段、矩形、图片框、条形码五类元素,每个元素支持鼠标拖动、八方向缩放、属性面板精确设置 Left/Top/Width/Height。
第二条,所见即所得。设计器画布的坐标系直接映射到打印页面的毫米坐标,屏幕上看到的布局和实际打印出来的效果一致。这里的关键是统一使用毫米作为逻辑单位,通过 GDI+ 的 PageUnit 属性做转换。
第三条,模板序列化。设计好的模板要能保存成文件、能重新加载、能在不同机器之间传递。我选的是 XML 序列化,因为 .NET 原生支持,不需要额外库,而且人类可读,调试方便。
第四条,数据绑定与批量打印。模板中的文本元素可以绑定数据源字段,打印时逐条替换。这部分用简单的字典映射实现,不引入复杂的表达式引擎。
2.2 技术选型:WinForms + GDI+ 的取舍
技术栈的选择上,我做了几个关键决策。
UI 框架用 WinForms 而不是 WPF。原因很直接:打印相关的 API 在 System.Drawing.Printing 命名空间下,和 WinForms 的 Graphics 体系天然契合。WPF 虽然界面更现代,但打印走的是 XPS 路线,和 GDI+ 之间需要转换层,反而增加复杂度。对于课程设计和中小型项目来说,WinForms 的开发效率更高,代码量更少,答辩时也更容易讲清楚。
绘图引擎用 GDI+ 而不是 Direct2D。GDI+ 是 .NET 原生自带的,System.Drawing 程序集直接引用即可。Direct2D 性能更好,但需要 P/Invoke 或者第三方封装,违背了“纯 .NET 无第三方”的原则。对于打印这种低频、非实时刷新的场景,GDI+ 的性能完全够用。
序列化用 XmlSerializer 而不是 JSON。虽然现在 JSON 更流行,但 XmlSerializer 对 .NET 对象的支持更成熟,特别是对于继承体系(所有控件元素继承自同一个基类),XML 的 xsi:type 机制可以自动处理多态序列化,JSON 需要额外配置。
提示:如果你的项目要求 .NET Core / .NET 5+,System.Drawing.Common 在非 Windows 平台上有兼容性限制。这套方案定位是 Windows 桌面应用,用 .NET Framework 4.6.1 或 .NET 6 Windows 目标框架都可以。
2.3 项目结构一览
整个解决方案分成三个项目:
PrintDesigner.sln ├── PrintDesigner.Core // 核心库:元素模型、序列化、打印引擎 ├── PrintDesigner.Designer // 设计器 UI:画布、工具箱、属性面板 └── PrintDesigner.Demo // 测试 Demo:加载模板、绑定数据、预览打印Core 库不引用任何 UI 程序集,只依赖 System.Drawing 和 System.Xml。这样做的目的是让打印引擎可以独立在服务端使用——比如 Web 系统生成打印文件时,直接调 Core 库即可,不需要启动窗体。
3. 总体架构设计:元素模型、画布交互与打印引擎的三层拆分
3.1 元素模型层:所有可拖拉控件的基类设计
整个设计器的核心是元素模型。我定义了一个抽象基类 PrintElement,所有可放置的控件都继承它:
// PrintElement.cs - 所有打印元素的抽象基类 [XmlInclude(typeof(TextElement))] [XmlInclude(typeof(LineElement))] [XmlInclude(typeof(RectangleElement))] [XmlInclude(typeof(ImageElement))] [XmlInclude(typeof(BarcodeElement))] public abstract class PrintElement { // 位置和尺寸,单位统一为毫米 public float Left { get; set; } public float Top { get; set; } public float Width { get; set; } public float Height { get; set; } // 元素名称,用于属性面板显示和数据绑定 public string Name { get; set; } // 数据绑定字段名,为空表示静态内容 public string DataField { get; set; } // 核心绘制方法,由子类实现 public abstract void Draw(Graphics g, float offsetX, float offsetY, Dictionary<string, string> data); // 命中测试:判断某个点是否落在元素范围内 public virtual bool HitTest(PointF point) { return new RectangleF(Left, Top, Width, Height).Contains(point); } // 克隆方法,用于复制粘贴 public abstract PrintElement Clone(); }这里有几个设计点值得展开。第一,XmlInclude 特性让 XmlSerializer 知道有哪些子类,序列化时自动写入 xsi:type 属性,反序列化时能正确还原类型。第二,Draw 方法接收 offsetX 和 offsetY 参数,这是为了支持画布滚动——画布滚动后,元素的逻辑坐标不变,但绘制时需要加上滚动偏移。第三,DataField 属性是数据绑定的入口,打印时如果这个字段不为空,就从数据字典里取值替换。
每个子类的 Draw 实现各司其职。TextElement 用 Graphics.DrawString 绘制文本,支持字体、字号、对齐方式;LineElement 用 DrawLine 绘制线段,支持线宽和线型;RectangleElement 用 DrawRectangle 或 FillRectangle;ImageElement 从文件加载图片后 DrawImage;BarcodeElement 稍微特殊,需要自己实现条形码的绘制逻辑。
3.2 画布交互层:拖拉、缩放与对齐辅助线
画布是设计器的交互核心。我继承 Panel 写了一个 DesignCanvas 控件,重写 OnPaint 做双缓冲绘制,重写鼠标事件处理拖拉和缩放。
// DesignCanvas.cs - 画布核心交互逻辑(节选) protected override void OnMouseDown(MouseEventArgs e) { // 将屏幕坐标转换为画布逻辑坐标(毫米) PointF logicPoint = ScreenToLogic(e.Location); // 先检测是否点中了已选中元素的缩放手柄 if (_selectedElement != null) { _activeHandle = HitTestHandle(logicPoint, _selectedElement); if (_activeHandle != HandleType.None) { _isResizing = true; _dragStart = logicPoint; return; } } // 从后往前遍历元素(后添加的在上面) for (int i = _elements.Count - 1; i >= 0; i--) { if (_elements[i].HitTest(logicPoint)) { _selectedElement = _elements[i]; _isDragging = true; _dragStart = logicPoint; Invalidate(); // 触发重绘,显示选中框 return; } } // 点空白处取消选中 _selectedElement = null; Invalidate(); }坐标转换是画布交互的基础。屏幕像素和毫米之间的换算依赖一个 ZoomFactor 缩放比例,默认设为 3.78(约 96 DPI 下 1 毫米对应的像素数)。ScreenToLogic 方法把鼠标坐标除以缩放比例得到毫米坐标,LogicToScreen 反过来。
缩放手柄我设计了八个方向:左上、上、右上、右、右下、下、左下、左。每个手柄是一个 6x6 像素的小方块,绘制在选中元素的边框上。HitTestHandle 方法判断鼠标是否落在某个手柄的命中区域内,返回对应的 HandleType 枚举值。拖动时根据手柄类型调整元素的 Left/Top/Width/Height,同时做最小尺寸限制,防止元素被拖成零宽或负宽。
对齐辅助线是提升设计效率的关键。当拖动元素时,实时检测当前元素的左边缘、水平中心、右边缘是否和其他元素的对应边缘接近(阈值设为 2 毫米),如果接近就自动吸附,并绘制一条红色虚线提示。这个功能实现起来不复杂,但用户体验提升很明显。
3.3 打印引擎层:毫米坐标到纸张的精确映射
打印引擎的职责是把设计好的元素列表渲染到打印机画布上。核心方法是 PrintDocument 的 PrintPage 事件处理:
// PrintEngine.cs - 打印页面渲染 private void OnPrintPage(object sender, PrintPageEventArgs e) { Graphics g = e.Graphics; // 设置单位为毫米,这样元素的坐标可以直接使用 g.PageUnit = GraphicsUnit.Millimeter; // 计算页面可打印区域的原点偏移 float offsetX = e.MarginBounds.Left / 100f * 25.4f; float offsetY = e.MarginBounds.Top / 100f * 25.4f; // 遍历所有元素,逐个绘制 foreach (var element in _template.Elements) { // 如果绑定了数据字段,从当前数据行取值 var data = _currentDataRow ?? new Dictionary<string, string>(); element.Draw(g, offsetX, offsetY, data); } // 判断是否还有下一页数据 e.HasMorePages = _dataSource.MoveNext(); }这里最关键的设置是g.PageUnit = GraphicsUnit.Millimeter。GDI+ 默认使用像素作为绘图单位,但打印场景下毫米更直观。设置之后,DrawString、DrawLine 等方法的坐标参数都直接按毫米解释,和设计器画布的逻辑坐标完全一致,不需要额外的换算。
MarginBounds 的处理需要注意。e.MarginBounds 返回的是以百分之一英寸为单位的矩形,需要除以 100 再乘以 25.4 转换成毫米。这个偏移量确保内容打印在可打印区域内,不会被打印机硬件边距裁掉。
批量打印的逻辑放在 PrintDocument 外部控制。调用方传入一个数据集合,每次 PrintPage 事件触发时取下一行数据,设置 HasMorePages 为 true 继续打印,数据耗尽时设为 false 结束。
4. 关键模块实现:序列化、属性面板与条形码绘制
4.1 模板的 XML 序列化与版本兼容
模板保存和加载用 XmlSerializer 实现,但直接序列化 List 会遇到多态问题。我的做法是包一层 TemplateDocument 类:
// TemplateDocument.cs - 模板文档容器 [XmlRoot("PrintTemplate")] public class TemplateDocument { [XmlAttribute("version")] public string Version { get; set; } = "1.0"; [XmlAttribute("pageWidth")] public float PageWidth { get; set; } = 210; // A4 宽度,毫米 [XmlAttribute("pageHeight")] public float PageHeight { get; set; } = 297; // A4 高度,毫米 [XmlArray("Elements")] [XmlArrayItem("Element")] public List<PrintElement> Elements { get; set; } = new List<PrintElement>(); // 保存到文件 public void Save(string path) { var serializer = new XmlSerializer(typeof(TemplateDocument)); using (var writer = new StreamWriter(path, false, Encoding.UTF8)) { serializer.Serialize(writer, this); } } // 从文件加载 public static TemplateDocument Load(string path) { var serializer = new XmlSerializer(typeof(TemplateDocument)); using (var reader = new StreamReader(path, Encoding.UTF8)) { return (TemplateDocument)serializer.Deserialize(reader); } } }Version 属性是为后续升级预留的。如果将来元素模型增加了新属性,加载旧版本模板时可以做兼容处理。比如新增了旋转角度属性,旧模板没有这个字段,反序列化后默认为 0,不影响使用。
序列化后的 XML 结构大致是这样:
<PrintTemplate version="1.0" pageWidth="210" pageHeight="297"> <Elements> <Element xsi:type="TextElement" Left="20" Top="15" Width="60" Height="8"> <Name>标题</Name> <DataField></DataField> <Text>出库单</Text> <FontFamily>宋体</FontFamily> <FontSize>16</FontSize> </Element> <Element xsi:type="BarcodeElement" Left="20" Top="30" Width="50" Height="15"> <Name>条形码</Name> <DataField>OrderNo</DataField> </Element> </Elements> </PrintTemplate>注意:xsi:type 属性需要 XML 命名空间声明。XmlSerializer 会自动处理,但手动编辑 XML 文件时要注意不要破坏命名空间。
4.2 属性面板:用 PropertyGrid 还是自建面板
属性编辑我最终选了自建面板而不是直接用 PropertyGrid。PropertyGrid 虽然开箱即用,但有两个问题:一是显示效果不可控,二是中文属性名需要额外用 DisplayName 特性标注,代码侵入性强。
自建面板的思路是:左侧放一个 ListBox 显示当前选中元素的属性名,右侧放一个 Panel 动态生成编辑控件。文本属性用 TextBox,数值属性用 NumericUpDown,颜色属性用 Button 弹出 ColorDialog。
// PropertyPanel.cs - 动态生成属性编辑控件(节选) private void BuildPropertyEditors(PrintElement element) { _propertyPanel.Controls.Clear(); int y = 10; // 通用属性:位置和尺寸 AddNumericEditor("Left", element.Left, y, v => element.Left = v); y += 30; AddNumericEditor("Top", element.Top, y, v => element.Top = v); y += 30; AddNumericEditor("Width", element.Width, y, v => element.Width = v); y += 30; AddNumericEditor("Height", element.Height, y, v => element.Height = v); y += 30; // 文本元素特有属性 if (element is TextElement textElem) { AddTextEditor("Text", textElem.Text, y, v => textElem.Text = v); y += 30; AddTextEditor("DataField", textElem.DataField, y, v => textElem.DataField = v); y += 30; } }每个编辑控件的值变化事件里,除了更新元素属性,还要调用画布的 Invalidate() 触发重绘,实现所见即所得。这里有个细节:NumericUpDown 的 ValueChanged 事件在用户输入过程中会频繁触发,如果每次都重绘画布,输入“100”时会触发三次重绘。我的做法是加一个 200 毫秒的防抖定时器,用户停止输入后再统一重绘。
4.3 条形码元素的绘制实现
条形码是打印模板里的高频需求,但 .NET 原生没有条形码库。我用 Code128 编码规则自己实现了一个简易版本。Code128 的特点是每个字符对应一个 11 位的二进制模式,编码表是公开的。
// BarcodeElement.cs - Code128 条形码绘制(简化版) public override void Draw(Graphics g, float offsetX, float offsetY, Dictionary<string, string> data) { string content = string.IsNullOrEmpty(DataField) ? _staticText : (data.ContainsKey(DataField) ? data[DataField] : ""); if (string.IsNullOrEmpty(content)) return; // 获取 Code128 编码的二进制串 string binary = Code128Encoder.Encode(content); // 计算每个模块的宽度(毫米) float moduleWidth = Width / binary.Length; float x = offsetX + Left; float y = offsetY + Top; // 逐位绘制:1 画黑条,0 留白 using (var brush = new SolidBrush(Color.Black)) { for (int i = 0; i < binary.Length; i++) { if (binary[i] == '1') { g.FillRectangle(brush, x + i * moduleWidth, y, moduleWidth, Height); } } } // 在条形码下方绘制可读文本 using (var font = new Font("Arial", 3)) { g.DrawString(content, font, Brushes.Black, x, y + Height + 1); } }Code128Encoder 的编码逻辑分三步:先根据字符集(A/B/C)选择最优编码方案,再把每个字符映射到对应的 11 位模式,最后拼接起始符、校验符和终止符。校验符的计算方法是:起始符值加上每个字符值乘以其位置权重,对 103 取模。
这个实现覆盖了数字和大小写字母的常见场景,对于课程设计和中小型项目足够用。如果需要支持 EAN-13 或 QR 码,可以按同样的思路扩展。
5. 避坑与常见问题:坐标、打印偏移和序列化踩过的坑
5.1 打印内容整体偏移或缩放不对
现象:设计器里预览正常,实际打印出来内容偏左或偏上,或者整体缩小了一圈。
原因:打印机的物理边距和 Graphics 的默认原点不一致。很多人直接用元素的 Left/Top 作为绘制坐标,忽略了打印机的硬边距(通常上下左右各 3-5 毫米)。另外,如果没设置 PageUnit 为 Millimeter,GDI+ 会按像素解释坐标,而打印机的 DPI 通常是 300 或 600,导致内容被严重缩小。
解决:在 PrintPage 事件里先设置g.PageUnit = GraphicsUnit.Millimeter,然后用e.MarginBounds计算偏移量。如果发现偏移量不对,可以用e.PageSettings.HardMarginX和HardMarginY获取打印机硬边距做补偿。我一般会在打印前先调PrintPreviewDialog预览一遍,确认布局无误再实际打印。
5.2 序列化后反序列化报“未知类型”错误
现象:模板保存成功,但加载时报 InvalidOperationException,提示“无法识别指定的类型”。
原因:XmlSerializer 在反序列化多态类型时,需要知道所有可能的子类。如果 PrintElement 的 XmlInclude 特性漏了某个子类,或者子类所在的程序集没有被加载,就会报这个错。
解决:确保基类上的 XmlInclude 列出了所有子类。如果子类分布在不同的程序集,需要在序列化之前手动加载这些程序集。另一个常见原因是 XML 文件里的 xsi:type 值写的是完整类型名(含命名空间),如果程序集的命名空间变了,反序列化就会失败。我的习惯是模板文件里只存相对类型名,加载时用自定义的 XmlTypeMapping 做映射。
5.3 画布拖动时元素闪烁严重
现象:拖动元素时画布频繁重绘,元素边缘闪烁,视觉体验很差。
原因:WinForms 的 Panel 默认不是双缓冲的,每次 Invalidate 都会先擦除背景再绘制,造成闪烁。
解决:在画布控件的构造函数里设置SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)。另外,拖动过程中不要每次都重绘所有元素,只重绘受影响的区域——用 Invalidate(Rectangle) 指定脏区域,而不是无参的 Invalidate()。
5.4 数据绑定字段为空时打印出空白
现象:模板里绑定了 DataField,但打印时某些行数据缺失,导致该元素打印为空白,而不是显示默认值。
原因:Draw 方法里直接从字典取值,如果 key 不存在就返回空字符串,没有做兜底处理。
解决:在数据绑定环节加一层默认值机制。我通常会在 TextElement 里加一个 DefaultText 属性,当 DataField 为空或字典里找不到对应 key 时,显示 DefaultText。这样即使数据不完整,打印出来的单据也不会出现莫名其妙的空白区域。
5.5 高 DPI 显示器上画布坐标错乱
现象:在 4K 显示器或缩放比例设为 150% 的机器上,鼠标点击位置和元素实际位置对不上。
原因:WinForms 的坐标默认是物理像素,但高 DPI 下系统会做缩放,导致鼠标事件里的坐标和控件内部坐标不一致。
解决:在应用程序清单里声明 DPI 感知级别,或者在 Program.cs 里调用Application.SetHighDpiMode(HighDpiMode.SystemAware)。然后在画布的坐标转换方法里,用DeviceDpi属性动态计算缩放比例,而不是写死 96。
6. 验收与答辩:怎么证明这套打印模板设计器真的能用
验收这套方案,我一般会走四个步骤,每一步都有明确的通过标准。
第一步,功能验收。打开 Demo 程序,从工具箱拖一个文本元素到画布,改内容为“测试打印”,再拖一个条形码元素,绑定字段“OrderNo”。保存模板,关闭程序,重新打开,加载模板,确认两个元素的位置、大小、属性都正确还原。这一步验证的是拖拉交互和序列化。
第二步,打印精度验收。用 A4 纸实际打印一份模板,拿尺子量文本元素左上角到纸张左上角的距离,和设计器里设置的 Left/Top 值对比,误差应在 1 毫米以内。这一步验证的是坐标映射的准确性。
第三步,批量打印验收。准备一个包含 10 条记录的数据集合,调用批量打印接口,确认每页打印一条记录,且每条记录的 DataField 都正确替换。这一步验证的是数据绑定和分页逻辑。
第四步,异常场景验收。故意加载一个格式错误的 XML 文件,确认程序给出友好提示而不是崩溃;故意绑定一个不存在的字段名,确认打印时显示默认值而不是空白。
答辩时,评委最常问的三个问题是:“为什么不用第三方控件?”“坐标精度怎么保证?”“模板格式能不能跨版本兼容?”前两个问题在第二章和第三章里有完整答案,第三个问题用 TemplateDocument 的 Version 属性来回答——加载时检查版本号,低版本模板自动升级字段,高版本模板提示不兼容。
| 验收项 | 通过标准 | 对应模块 |
|---|---|---|
| 控件拖拉 | 五类元素均可拖动、缩放、删除 | DesignCanvas |
| 属性编辑 | 修改属性后画布实时更新 | PropertyPanel |
| 模板存取 | 保存后重新加载,属性无丢失 | TemplateDocument |
| 打印精度 | 实际打印与设计布局误差 ≤ 1mm | PrintEngine |
| 数据绑定 | 字段替换正确,缺失字段有默认值 | TextElement.Draw |
| 批量打印 | 多页数据逐页输出,无重复无遗漏 | PrintDocument |
从那以后我每次交付打印相关模块,都会强制走一遍“设计-保存-重载-打印-量尺”的完整链路,不管需求多简单。因为打印这件事,屏幕上看着对和纸上真的对,中间隔着一堆玄学。希望这套方案能帮到你,源码和 Demo 都在,拿去改改就能用。
本文还有配套的精品资源,点击获取