简介:这是一套面向 .NET 开发者的 C# 可视化打印模板设计源码,核心价值在于所见即所得:通过拖拉控件即可完成模板编辑,无需依赖任何第三方控件,纯 .NET 实现,可直接嵌入现有项目或独立作为打印程序使用。它适用于发票、报告、证书、标签及带表体明细的单据类打印场景,仅需提供 Excel 数据即可驱动模板输出,并支持自动翻页,对需要快速搭建打印模块的中级开发者尤为实用。资源包共 639 个文件,约 89.25MB,包含 188 个 dll、182 个 xml、16 个 cs 源码及 png、xlsx、exe、sln 等,涵盖模板编辑器、图形设计工具与布局管理器等组件,并附带测试 demo 源代码,便于理解调用方式与二次开发。目前已有 1588 人学习下载,适合希望掌握打印模板设计思路、复用成熟组件或研究纯 .NET 打印方案的开发者参考。
1. 拖拽式打印模板设计器:一份纯 .NET 源码能省掉多少重复造轮子的时间
做过 ERP、MES 或者进销存的朋友大概率都遇到过同一个需求:让用户自己拖控件、调位置、改字体,最后打印出一张格式完全自定义的单据。听起来简单,真动手做的时候才发现坑一个接一个——控件拖动时的坐标换算、打印预览和实际输出的比例偏差、多页模板的分页逻辑、序列化保存后的反序列化还原,每一项都够折腾好几天。这份 C# 可视化打印模板设计源码,核心就是把这些脏活累活全封装好了:左侧控件面板拖到画布上,鼠标拉伸调整大小,属性面板实时改字体和边框,预览区所见即所得,最后直接调打印机输出。整套代码纯 .NET 实现,没有引用任何第三方 UI 控件库,拿到手就能编译运行,附带测试 Demo 和完整源代码。适合正在做报表模块的 .NET 开发者、需要给客户交付可定制打印功能的外包团队,以及想研究 GDI+ 打印底层逻辑的进阶学习者。下面我从源码结构、核心实现、实际跑起来的步骤到踩坑记录,完整拆一遍。
2. 源码结构拆解:从解决方案到打印引擎的完整链路
2.1 解决方案目录与项目依赖关系
拿到源码包后,第一件事不是急着 F5 运行,而是先把解决方案的目录结构看清楚。这份源码通常包含三个核心项目:设计器主程序(Designer)、打印核心库(PrintCore)、测试 Demo(TestApp)。三者之间的依赖关系是单向的——Designer 引用 PrintCore,TestApp 引用 Designer 和 PrintCore,不存在循环依赖。这种分层方式的好处是,你后续要把打印功能集成到自己项目里时,只需要引用 PrintCore 这一个 DLL,不用把整个设计器都搬过去。
用 Visual Studio 打开 .sln 文件后,在解决方案资源管理器里重点看这几个目录:
| 目录/文件 | 作用 | 是否可独立复用 |
|---|---|---|
| PrintCore/Controls | 自定义打印控件基类与派生类 | 是 |
| PrintCore/Serialization | 模板的 XML/JSON 序列化与反序列化 | 是 |
| PrintCore/Rendering | GDI+ 绘制引擎,负责预览和实际打印 | 是 |
| Designer/Canvas | 画布容器,处理拖拽、缩放、对齐 | 否,与 UI 强耦合 |
| Designer/PropertyGrid | 属性面板,反射绑定控件属性 | 否 |
| TestApp/Form1.cs | 测试入口,演示加载模板并打印 | 参考用 |
如果你只是想要打印功能,把 PrintCore 单独抽出来编译成类库,然后在自己的 WinForm 或 WPF 项目里引用即可。注意 WPF 项目引用 WinForm 类库时需要额外处理 GDI+ 和 WindowsFormsIntegration 的兼容问题,这个后面避坑章节会细说。
2.2 模板文件格式与序列化机制
模板保存是设计器最核心的功能之一。用户拖了半天控件,关掉程序再打开必须能还原,这就要求序列化机制足够健壮。这份源码一般提供两种保存格式:XML 和 JSON。XML 可读性好,方便手动改;JSON 体积小,适合网络传输。具体用哪种看你的场景,我一般建议默认走 XML,因为调试阶段可以直接打开文件看控件坐标对不对。
序列化的核心思路是:每个打印控件都实现一个ISerializable接口或者用[Serializable]特性标记,画布遍历所有子控件,把它们的类型名、位置、大小、文本内容、字体、边框等属性写成一个对象树,再统一序列化。反序列化时根据类型名反射创建实例,逐个还原属性。
// 模板序列化核心逻辑(简化版) [Serializable] public class PrintTemplate { public string TemplateName { get; set; } public float PageWidth { get; set; } // 单位:毫米 public float PageHeight { get; set; } public List<PrintControlBase> Controls { get; set; } } // 保存模板 public void SaveTemplate(PrintTemplate template, string filePath) { var serializer = new XmlSerializer(typeof(PrintTemplate)); using (var writer = new StreamWriter(filePath)) { serializer.Serialize(writer, template); } } // 加载模板 public PrintTemplate LoadTemplate(string filePath) { var serializer = new XmlSerializer(typeof(PrintTemplate)); using (var reader = new StreamReader(filePath)) { return (PrintTemplate)serializer.Deserialize(reader); } }这里有几个参数需要特别注意。PageWidth和PageHeight的单位是毫米而不是像素,因为打印机的坐标系是物理尺寸,像素只在屏幕上才有意义。如果你在序列化时存的是像素值,换一台 DPI 不同的机器打开模板,所有控件的位置都会偏移。源码里通常会在画布初始化时做一个 DPI 到毫米的换算,公式是毫米 = 像素 / DPI * 25.4。这个换算系数 25.4 是英寸到毫米的固定值,不要改。
另外Controls列表里存的是基类引用,反序列化时 XMLSerializer 需要知道所有可能的派生类型。常见做法是在基类上加[XmlInclude(typeof(TextControl))]、[XmlInclude(typeof(LineControl))]这样的特性,把所有子类都列出来。如果你后续自己加了新的控件类型,忘了加 XmlInclude,反序列化时会直接抛异常,而且异常信息很模糊,不容易定位。
2.3 拖拽与所见即所得的实现原理
拖拽看起来简单,实际上手写过的都知道,鼠标按下、移动、抬起这三个事件里藏着不少细节。核心逻辑是:MouseDown 时记录鼠标起始点和控件当前位置,MouseMove 时计算偏移量并更新控件 Location,MouseUp 时结束拖拽并触发对齐吸附。听起来就三句话,但实际代码里要处理边界检测(不能拖出画布)、多选拖拽(多个控件同时移动)、撤销重做(每次拖拽前保存状态)这些附加逻辑。
// 拖拽核心逻辑(简化版) private Point _mouseOffset; private bool _isDragging = false; private void Control_MouseDown(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { _isDragging = true; // 计算鼠标相对于控件左上角的偏移 _mouseOffset = new Point(e.X, e.Y); // 记录拖拽前的状态,用于撤销 SaveUndoState(); } } private void Control_MouseMove(object sender, MouseEventArgs e) { if (!_isDragging) return; var ctrl = (Control)sender; // 计算新位置 int newX = ctrl.Left + e.X - _mouseOffset.X; int newY = ctrl.Top + e.Y - _mouseOffset.Y; // 边界限制:不能拖出画布 newX = Math.Max(0, Math.Min(newX, canvas.Width - ctrl.Width)); newY = Math.Max(0, Math.Min(newY, canvas.Height - ctrl.Height)); ctrl.Location = new Point(newX, newY); // 触发重绘,更新预览 canvas.Invalidate(); } private void Control_MouseUp(object sender, MouseEventArgs e) { _isDragging = false; // 吸附对齐:如果靠近其他控件的边缘,自动对齐 SnapToNearestControl((Control)sender); }_mouseOffset这个变量是关键。如果不记录偏移量,直接用e.X和e.Y设置控件位置,控件会瞬间跳到鼠标位置,而不是跟随鼠标平滑移动。这个偏移量的本质是鼠标在控件内部的相对位置,保证拖拽时鼠标和控件的相对关系不变。
SnapToNearestControl是吸附对齐函数,遍历画布上所有其他控件,如果当前控件的左边缘和某个控件的左边缘距离小于 5 像素,就自动对齐。这个阈值 5 像素是经验值,太小了吸附不灵敏,太大了容易误吸附。源码里通常还会画一条辅助线提示对齐位置,这个辅助线是在OnPaint里绘制的,不参与实际打印输出。
所见即所得的核心在于预览区和画布使用同一套绘制逻辑。也就是说,PrintControlBase里应该有一个Draw(Graphics g)方法,画布调用它来显示,打印时也调用它来输出。区别只在于传入的 Graphics 对象不同——画布传的是屏幕 Graphics,打印传的是 PrinterGraphics。如果源码里画布和打印用了两套绘制代码,那预览和实际输出必然有偏差,这是很多简易打印模块的通病。
3. 从编译到打印:把源码跑起来的完整操作流程
3.1 环境准备与编译注意事项
这份源码是纯 .NET 实现,没有第三方控件依赖,理论上 Visual Studio 2015 及以上版本都能直接打开。但实际编译时,目标框架版本决定了你能用哪些 API。如果源码里用了Span<T>或者ValueTuple这类较新的语法,就需要 .NET Framework 4.7.2 以上或者 .NET Core 3.1 以上。打开项目后先看项目属性的「目标框架」一栏,如果显示的是 .NET Framework 4.5 这种老版本,而你本机只装了 4.8 的运行时,编译时可能会提示找不到引用程序集。
常见做法是右键项目 → 属性 → 目标框架,改成你本机已安装的版本。改完之后如果报错,检查一下app.config里的supportedRuntime节点,把版本号同步改掉。另外注意平台目标(x86/x64/AnyCPU),打印相关的 GDI+ 调用在 64 位下一般没问题,但如果源码里引用了某些 32 位的 COM 组件(比如某些老式打印机驱动),就必须改成 x86。
编译前还有一件事:检查packages.config或者.csproj里有没有 NuGet 包引用。虽然标题说没有第三方控件,但可能引了Newtonsoft.Json这类基础库。如果有,右键解决方案 → 还原 NuGet 包,等还原完成再编译。如果还原失败,大概率是网络源的问题,换成国内镜像源再试。
3.2 设计器界面操作与模板制作
编译通过后直接 F5 运行,主界面一般分四个区域:左侧控件工具箱、中间画布、右侧属性面板、底部预览区。操作流程和大多数设计器一样:从左侧拖一个「文本控件」到画布上,选中它,在右侧属性面板改文本内容、字体大小、对齐方式。再拖一个「线条控件」画表格线,拖一个「图片控件」放 Logo。
这里有一个容易被忽略的点:画布的尺寸默认是 A4 纸的尺寸(210mm × 297mm),但屏幕显示时是按像素缩放的。如果你在画布上把控件放在正中间,打印出来不一定在正中间,因为屏幕 DPI 和打印机 DPI 不同。源码里通常会在画布初始化时做一个缩放比例,比如zoomFactor = 96 / 300,96 是屏幕典型 DPI,300 是打印机典型 DPI。这个比例决定了画布上 1 像素对应打印时的多少毫米。
制作模板时建议先画表格框架,再往里填内容。因为表格线通常需要精确对齐,先放文本控件再画线,线容易和文本重叠。另外属性面板里的「边框」属性要慎用,每个控件都加边框的话,打印出来线条会加粗,看起来像表格线重影。正确做法是用线条控件画表格,文本控件不加边框。
模板做完后点保存,选择 XML 格式,存到本地。然后关掉设计器,重新打开,加载刚才保存的模板文件,检查控件位置和属性是否完全还原。这一步是验证序列化机制是否健壮的关键,如果还原后控件位置偏移了,说明序列化时存的坐标单位有问题。
3.3 调用打印与预览的代码集成
设计器只是工具,最终目的是把模板集成到自己的业务系统里。TestApp 里通常有一个PrintHelper类,封装了加载模板、绑定数据、调用打印三个步骤。
// 打印调用示例 public void PrintInvoice(string templatePath, InvoiceData data) { // 1. 加载模板 var template = LoadTemplate(templatePath); // 2. 绑定数据:遍历模板中的控件,把占位符替换成实际数据 foreach (var ctrl in template.Controls) { if (ctrl is TextControl textCtrl) { // 假设文本内容里用 {InvoiceNo} 这样的占位符 textCtrl.Text = ReplacePlaceholder(textCtrl.Text, data); } } // 3. 创建打印文档并输出 var printDoc = new PrintDocument(); printDoc.DefaultPageSettings.PaperSize = new PaperSize("Custom", (int)(template.PageWidth / 25.4 * 100), (int)(template.PageHeight / 25.4 * 100)); printDoc.PrintPage += (sender, e) => { foreach (var ctrl in template.Controls) { ctrl.Draw(e.Graphics); // 复用控件的绘制逻辑 } }; printDoc.Print(); }PaperSize的构造函数里,宽高单位是 1/100 英寸,所以要把毫米先除以 25.4 转成英寸,再乘以 100。这个换算很容易搞错,搞错了打印出来要么被截断,要么留白太多。PrintPage事件里遍历所有控件调用Draw方法,这就是前面说的「预览和打印共用同一套绘制逻辑」的落地方式。
数据绑定部分,源码里可能用占位符替换,也可能用数据源绑定。占位符替换更灵活,适合模板格式不固定的场景;数据源绑定更适合表格类数据,比如订单明细行。如果模板里有表格且行数不固定,需要额外处理分页逻辑——当一行数据超出页面底部时,自动换页并重新绘制表头。这部分源码里如果有实现,重点看PrintPage事件里有没有e.HasMorePages = true的逻辑。
提示:调用
printDoc.Print()之前,建议先调PrintPreviewDialog让用户确认一下。直接打印如果格式有问题,浪费纸张是小事,打出一堆废单才尴尬。
4. 避坑与排查:拖拽打印模板最容易翻车的五个地方
4.1 预览正常但打印偏移
现象:预览区看控件位置都对,一点打印,所有内容整体往右下角偏移了几毫米。原因通常是打印机的硬边距(HardMargin)没有处理。很多打印机不支持无边距打印,左右上下各有 3-5mm 的不可打印区域。GDI+ 默认从可打印区域的左上角开始绘制,但你的模板坐标是从纸张边缘算起的,两者差了一个边距值。解决办法是在PrintPage事件里先取e.PageSettings.HardMarginX和HardMarginY,在绘制前把 Graphics 的原点平移回去。
4.2 序列化后控件丢失
现象:保存模板时明明有十几个控件,重新加载后只剩几个,或者直接报反序列化异常。原因一般是 XMLSerializer 不认识派生类型。前面提过,基类上必须加[XmlInclude]标记所有子类。如果你自己扩展了新控件类型,忘了加这个特性,序列化时不会报错(因为序列化时用的是实际类型),但反序列化时 XMLSerializer 不知道这个类型名对应哪个类,就会静默丢弃或者抛异常。排查方法是打开保存的 XML 文件,看丢失的控件对应的节点类型名是什么,然后在基类上补上对应的 XmlInclude。
4.3 拖拽时控件闪烁严重
现象:拖动控件时画面闪烁,拖得越快闪得越厉害。原因是每次 MouseMove 都触发了整个画布的 Invalidate,导致所有控件重绘。解决办法是给画布容器开启双缓冲:把DoubleBuffered属性设为 true,或者自定义一个继承自 Panel 的类,在构造函数里设置SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true)。另外拖拽过程中只需要重绘被拖拽控件所在的区域,用Invalidate(ctrl.Bounds)代替Invalidate(),能进一步减少重绘面积。
4.4 高 DPI 显示器上坐标错乱
现象:在 4K 显示器上运行设计器,拖拽控件时鼠标位置和控件位置对不上,偏得很离谱。原因是 WinForm 默认不感知 DPI 缩放,系统会把整个窗口拉伸,导致鼠标坐标和控件坐标不在同一个坐标系里。解决办法是在app.manifest里声明 DPI 感知,或者在程序入口处调用Application.SetHighDpiMode(HighDpiMode.SystemAware)(.NET Core 3.0+)。如果用的是老版本 .NET Framework,需要在app.config里加<add key="EnableWindowsFormsHighDpiAutoResizing" value="true" />。这个坑在笔记本外接显示器的场景下特别常见。
4.5 打印多页时表头不重复
现象:模板里有一个表格,数据超过一页时,第二页没有表头,直接接着第一页的数据继续打。原因是PrintPage事件里没有处理分页逻辑。正确做法是在绘制每一行数据前判断当前 Y 坐标是否超过页面可打印区域的下边界,如果超过了,设置e.HasMorePages = true,然后在下一页的PrintPage事件里先重绘表头,再继续绘制剩余数据。这里要注意,PrintPage事件会被多次触发,每次触发时你需要知道上次画到哪一行了,通常用一个成员变量记录当前行索引。
5. 进阶技巧:把模板设计器嵌入自己的业务系统
5.1 以类库方式集成 PrintCore
如果你不想让用户看到完整的设计器界面,只想在自己的系统里提供一个「打印」按钮,那就只需要引用 PrintCore 类库。把 PrintCore 项目单独编译成 DLL,在你的主项目里添加引用。然后加载预先用设计器做好的模板文件,绑定数据,直接调打印。这种方式适合模板格式由管理员统一维护、普通用户只负责打印的场景。
集成时注意一点:PrintCore 里如果引用了System.Drawing,在 .NET Core 或 .NET 5+ 项目里需要额外安装System.Drawing.CommonNuGet 包。而且从 .NET 6 开始,System.Drawing.Common在非 Windows 平台上是受限的,如果你有跨平台需求,打印模块只能跑在 Windows 上。这是 GDI+ 的天然限制,不是源码的问题。
5.2 动态添加自定义控件类型
源码里预置的控件类型通常只有文本、线条、图片、矩形这几种。如果你的业务需要条形码或二维码,就需要自己扩展。扩展步骤分三步:第一,新建一个类继承PrintControlBase,重写Draw方法,在里面调用条形码生成库绘制;第二,在基类的 XmlInclude 列表里加上这个新类型;第三,在设计器的工具箱面板里注册这个新控件,让用户可以拖拽。
// 自定义条形码控件示例 [XmlInclude(typeof(BarcodeControl))] public class BarcodeControl : PrintControlBase { public string BarcodeValue { get; set; } public string Symbology { get; set; } = "CODE128"; public override void Draw(Graphics g) { // 调用条形码绘制逻辑(此处用伪代码表示) var barcodeBitmap = GenerateBarcode(BarcodeValue, Symbology, Width, Height); g.DrawImage(barcodeBitmap, Left, Top, Width, Height); } }Symbology属性决定条形码类型,CODE128 是最通用的,支持数字和字母。如果你只需要打数字,EAN13 更紧凑。生成条形码位图的逻辑可以自己写,也可以用现成的开源库。注意条形码的宽度和高度比例要固定,拉伸变形会导致扫码枪读不出来。
5.3 模板版本管理与兼容性处理
业务系统跑久了,模板文件会越来越多,格式也可能升级。比如第一版模板只支持文本控件,第二版加了表格控件,第三版改了坐标单位。这时候就需要版本管理。常见做法是在模板文件的根节点加一个Version属性,加载时先读版本号,如果版本低于当前程序支持的版本,走一个升级函数把老格式转成新格式。
升级函数不需要很复杂,通常就是补默认值、改属性名、转换坐标单位这几件事。关键是升级逻辑要幂等——同一个老模板升级两次,结果应该一样。我一般会在升级前先备份原文件,升级失败还能回滚。这个习惯是从一次惨痛经历来的:有个客户的模板文件升级到一半程序崩了,原文件被覆盖,几百张模板全废了,最后只能从备份恢复。从那以后我每次做格式升级都强制走一遍「备份 → 升级 → 验证 → 替换」的流程。
注意:模板文件的版本号建议用整数递增,不要用日期或字符串。整数比较大小最可靠,日期格式在不同区域设置下解析结果可能不一样。
希望帮到你。
本文还有配套的精品资源,点击获取