ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于WinForms的自定义打印设计:PrintDocument与模板分离实战

基于WinForms的自定义打印设计:PrintDocument与模板分离实战 简介面向.NET桌面应用开发者的Windows窗体自定义打印设计工具实现内含完整C#源码与可直接运行的Print.Test示例项目适用于在传统Winform程序中自定义报表、标签、票据等版式并通过代码动态调用系统打印组件完成输出。压缩包共563个文件以DLL类库、XML配置、PDB调试符号、CS源码文件为主另含NuGet依赖包、EXE可执行程序及解决方案等整体大小56.26MB目录按设计界面、打印预览、打印逻辑、控制代码等模块划分便于对照查阅和二次开发。已有3713人学习下载。内容围绕System.Drawing.Printing命名空间展开详细演示PrintDocument核心对象、PrintPage事件、PageSettings页面设置、PrintController打印控制以及用Graphics对象绘制文本、图片、二维码的完整流程同时展示了自定义控件/窗体中元素的拖放与数据存储、根据元素可见状态决定是否绘制、虚拟打印预览与真实打印相统一的设计思路。读者既可以借鉴这套一体化实现方案也能直接获得可集成到业务系统中的自定义打印工具代码对需要实现单据、标签、证书等打印功能的C#开发者具有较高的参考复用价值。1. 自定义打印设计工具模板与代码分离才是核心价值做进销存或者MES系统的多半都被打印需求折磨过。客户上午说标签加一行字下午说条码宽度调细一档你从改代码到重新编译部署半天没了。这个WinForms自定义打印设计工具解决的正是这个痛点把打印模板设计从代码里剥离出来实施人员直接在界面上拖拽控件、保存模板运行期通过C#代码动态加载调用。底层还是用.NET的PrintDocument组件做输出但加了一层设计器和序列化让改格式不动代码成为可能。适合正在做桌面端单据打印、标签打印、报表打印的.NET工程师参考。2. WinForms打印组件PrintDocument、PrintPageEventArgs与GDI绘制先把这个工具的底层搞明白。所有打印功能最后都落在System.Drawing.Printing命名空间那几类身上核心是PrintDocument。它不负责绘制只负责调起打印任务、调度页面真正画东西的是你挂在PrintPage事件上的回调。理解这一点整个工具的设计就清晰了——设计器管模板PrintDocument管输出中间的桥接层就是打印服务类。2.1 打印三件套最小可用的PrintDocument代码先看一段最简打印代码private void OnPrintPage(object sender, PrintPageEventArgs e) { // e.Graphics 是所有绘制操作的目标画布 // e.PageBounds 是纸张物理边界含不可打印区域 // e.MarginBounds 是去边距后的可打印区域 e.Graphics.DrawString(Hello Print, new Font(微软雅黑, 20), Brushes.Black, 100, 100); e.HasMorePages false; } var doc new PrintDocument(); doc.PrintPage OnPrintPage; doc.Print();这段代码就是一个完整的WinForms打印闭环。doc.Print()是同步调用会阻塞当前线程文档大了界面会卡住。OnPrintPage里所有绘制都用e.Graphics它是System.Drawing.Graphics的实例跟你在控件上画图用的是同一个类但坐标解释和DPI默认值有区别。几个关键参数的含义e.Graphics绘制画布DrawString、DrawLine、DrawImage都画在这上面e.PageBounds整张纸的物理区域单位是百分之一英寸e.MarginBounds去掉边距后的可打印区域受DefaultPageSettings.Margins影响e.HasMorePages置为true时PrintDocument触发下一页的PrintPage置为false则结束任务这里最容易误解的是单位。e.PageBounds和e.MarginBounds的单位是百分之一英寸而e.Graphics默认的PageUnit是Display1/96英寸。两者不是一个单位你要是直接拿PageBounds.Width去当Graphics的绘图宽度用打印区域会偏大。这个单位坑后面避坑章节会细说。2.2 坐标体系设计像素与打印物理尺寸的换算很多第一次写打印代码的人习惯直接把控件Location.X当打印坐标用。这么做大多数时候能跑但碰上需要精确尺寸的场景就露馅了。PrintDocument的Graphics默认PageUnit是Display也就是1/96英寸跟屏幕逻辑像素一致。如果你的设计器在96DPI下设计、坐标原样存储打印时直接套用确实差不多。但问题是很多用户的屏幕是125%缩放甚至150%。你在这种机器上做设计器控件坐标偏大存下来的模板打印出来就会整体偏移。常见做法有两种。第一种设计器坐标系固定96DPI存模板时全部按96DPI基准存打印时Graphics默认就是Display天然匹配不用换算。第二种设计器坐标跟随屏幕DPI存储时除以DPI缩放系数归一到96DPI。我推荐第一种最省心也不容易翻车。换算逻辑长这样// 设计器存储统一按96DPI基准归一 var element new TemplateElement { X (int)(control.Left / dpiScale * 96f), Y (int)(control.Top / dpiScale * 96f), Width (int)(control.Width / dpiScale * 96f), Height (int)(control.Height / dpiScale * 96f) };dpiScale怎么拿屏幕DPI除以96即可。WinForms里可以从CreateGraphics()的DpiX拿或者用Control.DeviceDpiVS2015及以上都支持。比如125%缩放下DeviceDpi是120dpiScale120/961.25。存储时坐标除以1.25打印时坐标就回到了96DPI基准空间。提示如果你在已有项目里已经用了别的DPI换算别急着改先理清楚哪一层在做换算再做归一化处理。这套恒为96DPI基准的思路要贯穿设计器、序列化、打印三个模块。所有坐标错乱问题归根结底都是坐标系没统一。设计器里画布控件坐标用逻辑像素存储的XML也是同一套逻辑像素打印时Graphics默认就能对上。谁要是中途夹了一个DPI换算这个平衡就打破了。打印之前一定要设置纸张大小否则默认是A4。自定义纸张在PrintDocument里是这样设的var doc new PrintDocument(); doc.DefaultPageSettings.PaperSize new PaperSize(Custom, (int)(widthMm / 25.4f * 100), (int)(heightMm / 25.4f * 100));PaperSize构造函数的单位是百分之一英寸。一般业务单据用毫米定义所以要除以25.4再乘100。千万别直接传像素值否则打印出来尺寸完全不对。很多标签打印机驱动对自定义纸张比较敏感我建议在代码里把纸张尺寸做成模板的一个属性设计模板时一并设置。3. 模板设计器拖拽实现、XML序列化与动态调用桥接设计器是整个工具的门面但它的核心不只是拖拽。拖拽只是交互真正决定工具好不好用的是两件事模板怎么存、打印时怎么正确还原。我把这两个都讲清楚你拿着就能在自己项目里落地。3.1 画布拖拽自定义控件与鼠标事件设计器要让用户把Label、Image、Line这些元素拖到画布上并且能拖动、调整大小。常见的做法是给每种元素定义一个UserControl子类共用一套拖拽逻辑。我用一个基类搞定public class DesignElementControl : UserControl { public string DataField { get; set; } // 绑定的数据字段名 public ElementType ElementType { get; set; } private bool dragging; private Point dragStart; protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); dragging true; dragStart e.Location; // 记住鼠标在控件内的相对位置 } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (!dragging) return; var parent Parent as DesignCanvas; var screenPoint PointToScreen(e.Location); var parentPoint parent.PointToClient(screenPoint); Left parentPoint.X - dragStart.X; Top parentPoint.Y - dragStart.Y; parent.OnElementMoved(this); } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); dragging false; } }这段拖拽代码里真正容易出错的是坐标换算。很多人会在MouseMove里直接用e.Location加减但e.Location是相对于当前控件左上角的一旦控件移动了下一次事件里的e.Location基准也跟着变了就会出现拖拽跳动的现象。所以我先PointToScreen转成屏幕坐标再PointToClient转到画布坐标绕开这个坑。属性面板我建议做成类似VS属性面板那样的列表ElementType不同属性集合也不同。LabelElement显示字体、字号、加粗、DataFieldImageElement显示FilePath、拉伸方式LineElement显示线条粗细、颜色。别自己写一大堆ComboBox去堆界面直接PropertyGrid控件把元素的公开属性暴露给它省一半工作量。做工具界面美化之前先把功能逻辑理顺属性面板能不能用比好不好看重要得多。3.2 模板序列化XmlSerializer与多态处理模板要落地必须能存文件。我用XmlSerializer序列化结构设计如下Template Version1.0 Page Width800 Height1200 MarginLeft50 MarginTop50 / Elements Label Name订单号 DataFieldOrderNo X50 Y60 Width120 Height24 FontName微软雅黑 FontSize12 / Line Name分隔线 X50 Y120 Width700 Height1 LineWidth1 / Image NameLogo FilePathlogo.png X50 Y20 Width60 Height20 / /Elements /Template对应的C#类型[XmlRoot(Template)] public class PrintTemplate { [XmlElement(Page)] public PageSetting Page { get; set; } [XmlArray(Elements)] [XmlArrayItem(Label, typeof(LabelElement))] [XmlArrayItem(Image, typeof(ImageElement))] [XmlArrayItem(Line, typeof(LineElement))] public ListTemplateElement Elements { get; set; } } public abstract class TemplateElement { [XmlAttribute] public string Name { get; set; } [XmlAttribute] public string DataField { get; set; } [XmlAttribute] public float X { get; set; } [XmlAttribute] public float Y { get; set; } [XmlAttribute] public float Width { get; set; } [XmlAttribute] public float Height { get; set; } } public class LabelElement : TemplateElement { [XmlAttribute] public string FontName { get; set; } [XmlAttribute] public float FontSize { get; set; } [XmlAttribute] public bool FontBold { get; set; } }多态集合是这里最大的坑。XmlSerializer默认对List 只会序列化基类属性子类特有的FontName、FontSize、FilePath全丢。必须用XmlArrayItem显式声明每个具体类型。你要是偷懒把Elements声明成List注意XmlSerializer多态集合漏声明类型是最隐蔽的错误它不报错只是静默丢失子类属性。序列化和反序列化的代码就两行// 保存模板 var serializer new XmlSerializer(typeof(PrintTemplate)); using (var fs File.Create(filePath)) serializer.Serialize(fs, template); // 加载模板 using (var fs File.OpenRead(filePath)) return (PrintTemplate)serializer.Deserialize(fs);保存前我建议做一次模板校验元素不能超出画布边界DataField不能为空。校验不通过直接弹提示别把脏数据写进文件。这个习惯能省很多后期排查时间。3.3 运行时打印从模板到PrintDocument的桥接模板设计好了业务代码怎么调用我提供一个PrintService统一入口对外暴露一个Print方法内部完成加载模板、准备数据、创建PrintDocument、触发打印public class PrintService { private PrintTemplate template; private Dictionarystring, string dataSource; public void Print(string templatePath, Dictionarystring, string data) { template PrintTemplate.LoadFromFile(templatePath); dataSource data; var doc new PrintDocument(); // 纸张尺寸来自模板单位换算成百分之一英寸 doc.DefaultPageSettings.PaperSize new PaperSize(Custom, (int)(template.Page.Width / 96f * 100), (int)(template.Page.Height / 96f * 100)); doc.PrintPage OnTemplatePrintPage; doc.Print(); } private void OnTemplatePrintPage(object sender, PrintPageEventArgs e) { foreach (var element in template.Elements) { switch (element) { case LabelElement label: using (var font new Font(label.FontName, label.FontSize, label.FontBold ? FontStyle.Bold : FontStyle.Regular)) { var content ResolveContent(label.DataField); e.Graphics.DrawString(content, font, Brushes.Black, element.X, element.Y); } break; case ImageElement image: using (var img Image.FromFile(image.FilePath)) { e.Graphics.DrawImage(img, element.X, element.Y, element.Width, element.Height); } break; } } e.HasMorePages false; } }这个桥接层三个决策值得说明。第一每次Print都重新加载模板文件牺牲一点性能换灵活性——改完模板立即生效不用重启程序实施人员会感谢你的。第二数据源用Dictionarystring, string而不是强类型实体模板里的DataField和业务模型解耦。第三Font、Image用完即Dispose这是GDI对象的铁律泄漏多了会出现打印几次后界面控件画不出来的玄学问题。调用方式很简洁var service new PrintService(); var data new Dictionarystring, string { { OrderNo, SO-20250110-001 }, { Receiver, 张三 }, { Address, 上海市浦东新区XX路100号 } }; service.Print(templates/shipping_label.xml, data);到这里一个能用的自定义打印设计工具就跑通了设计器里拖控件、存XML业务代码里给数据、调Print。下一章讲的是让它真正好用的细节。4. 对齐辅助线与分页策略设计器可用的分水岭拖拽能做出来很容易但设计器能不能让人愿意用还得看对齐、缩放、分页这些细节处理得怎么样。4.1 对齐辅助线与网格吸附没有对齐线的设计器拖出来的模板歪七扭八用户用一次就骂一次。我实现了一套简单的对齐参考线控件移动时检测它跟画布边界、其他控件的边界距离小于阈值就自动吸附。private const int SnapThreshold 6; private void CheckAlignment(DesignElementControl moving) { // 画布左中右三条参考线 var canvasLeft 0; var canvasCenter Width / 2; var canvasRight Width; if (Math.Abs(moving.Left - canvasLeft) SnapThreshold) moving.Left canvasLeft; if (Math.Abs(moving.Left moving.Width / 2 - canvasCenter) SnapThreshold) moving.Left canvasCenter - moving.Width / 2; if (Math.Abs(moving.Right - canvasRight) SnapThreshold) moving.Right canvasRight; // 与其他控件的边界对齐 foreach (var other in Controls.OfTypeDesignElementControl()) { if (other moving) continue; if (Math.Abs(moving.Left - other.Left) SnapThreshold) moving.Left other.Left; if (Math.Abs(moving.Top - other.Top) SnapThreshold) moving.Top other.Top; } }SnapThreshold设6像素是经验值。太小了吸附没感觉太大了会导致莫名其妙的跳变吸附。画布背景再画上点阵网格用户就能明显感觉到设计器的手感。这个对齐检测在OnMouseMove里每帧调用性能上完全没有压力。4.2 缩放与坐标基准别让缩放污染存储坐标很多人做设计器时想加放大缩小功能我劝你谨慎。一旦允许用户把画布缩放到80%控件的位置显示就会和存储坐标脱节你需要维护两套坐标系的换算来回倒腾精度就丢了打印出来偏几毫米都很正常。如果一定要做视觉缩放正确姿势是只影响绘制、不影响存储protected override void OnPaint(PaintEventArgs e) { e.Graphics.ScaleTransform(zoomFactor, zoomFactor); // 下面按逻辑坐标绘制所有元素 DrawElements(e.Graphics); }这里的关键ScaleTransform只作用于当前Graphics的变换矩阵控件的Location、Size属性仍然是逻辑坐标存储时不需要任何换算。你如果在缩放时去改控件的位置属性等于污染了数据源后面排查偏位问题会非常痛苦。另外设计器初期不用急着做撤销重做。先把模板能存、能加载、能打印跑通再考虑历史记录。很多人一开始就搞复杂的命令模式结果拖拽逻辑还没稳定就陷入无穷无尽的边界处理。我把撤销叫后悔药功能排期永远放最后实践下来是对的。4.3 多页打印HasMorePages的正确分页业务单据超过一页时靠e.HasMorePages控制。但你要在PrintPage回调里维护好当前打印到第几行这个状态。明细区域就是这么处理的private int currentRow; // 当前打印行必须实例字段 private DataTable detailData; // 明细数据 private const int RowsPerPage 20; // 每页行数 private const int RowHeight 25; // 行高(1/96英寸) private void OnDetailPrintPage(object sender, PrintPageEventArgs e) { DrawFixedHeader(e.Graphics); // 表头每页固定打印 DrawFixedFooter(e.Graphics); // 页脚每页固定打印 var y HeaderHeight; var rowsPrinted 0; while (currentRow detailData.Rows.Count rowsPrinted RowsPerPage) { DrawDetailRow(e.Graphics, detailData.Rows[currentRow], y); currentRow; y RowHeight; rowsPrinted; } e.HasMorePages currentRow detailData.Rows.Count; }currentRow必须设计成实例字段不能是局部变量。因为PrintPage每打印一页触发一次如果每次触发时currentRow都从0开始HasMorePages永远为true打印机就会无限出纸。这个坑太经典了我见过不止一个同事在这里翻车。另外RowsPerPage和RowHeight这两个参数我建议从模板配置里读取而不是写死。不同单据的明细行高不一样标签和A4纸的行数也不一样。把这些做成模板属性设计器里就能调业务上灵活很多。表头、页脚的位置也是模板的一部分不要在代码里硬编码。5. 避坑指南WinForms打印组件的五个典型翻车点这些坑都是实际项目里踩过的按现象、原因、解决三个步骤列出来你遇到类似问题直接查。5.1 坑一打印机DPI坐标紊乱导致文字偏移现象同一份模板在普通A4打印机上正常换到标签机上文字偏移好几毫米越靠近纸张边缘偏移越明显。原因标签打印机驱动通常返回300或600DPI而PrintDocument的Graphics默认PageUnit是Display1/96英寸。如果你在代码里手动设置了Graphics.PageUnit GraphicsUnit.Point或者Pixel坐标解释方式就完全变了。特别是Pixel模式下打印机物理DPI会被额外缩放偏移量肉眼可见。解决不要手动改PageUnit全程保持默认的Display单位。字体、坐标、图片全用同一套坐标系。如果确实需要Point单位1/72英寸换算公式是坐标乘以72再除以96。另外设计器存储坐标时已经统一到96DPI基准打印时不再做任何二次DPI换算。5.2 坑二中文字体打印成方框现象设计器里显示正常的中文打印出来变成□□□□或者乱码换字体又恢复正常。原因打印机字体子集化的时候某些字体没有正确的字形映射。常见于模板里指定了不存在的字体名或者用了仅含英文字形的字体比如某些老系统迁移过来的模板。解决模板加载时先验证字体存在性再验证样式可用性private bool IsFontAvailable(string fontName) { foreach (var family in FontFamily.Families) { if (family.Name fontName) { return family.IsStyleAvailable(FontStyle.Regular); } } return false; }不存在的字体回退到微软雅黑。我一般在模板校验阶段就做这个检查保存的时候直接提示用户不在打印阶段才暴露。5.3 坑三HasMorePages导致无限出纸现象打印任务不结束打印机不停出纸任务管理器里进程CPU占满。原因分页判断条件写错最常见的是分页计数器没自增或者每次PrintPage都重置状态。PrintDocument在HasMorePagestrue时会再次触发PrintPage判断条件恒为true就成了死循环。解决分页状态必须是实例字段且在开始打印前重置。我在PrintService里做了一层保护Print方法开头强制重置所有分页状态public void Print(string templatePath, Dictionarystring, string data) { currentRow 0; // 强制重置 // ... 其余逻辑 }这样即使同一个PrintService实例连续打印两份文档也不会互相污染分页状态。5.4 坑四后台线程打印与UI跨线程访问现象在BackgroundWorker或Task里调用打印界面卡死或者抛InvalidOperationException线程间操作无效。原因PrintDocument.Print()是同步阻塞的。UI线程调用会卡界面后台线程调用如果PrintPage事件里读了TextBox.Text、Label.Text这类UI控件属性就触发跨线程异常。解决进入后台线程前把要打印的数据快照成普通字典。PrintPage事件里只读快照不碰任何UI控件var snapshot new Dictionarystring, string { { OrderNo, txtOrderNo.Text }, { Receiver, txtReceiver.Text } }; Task.Run(() { var service new PrintService(); service.Print(templates/label.xml, snapshot); });还要注意PrintDocument实例不要跨线程复用。每次打印任务都new一个用完就扔别搞池化。5.5 坑五预览与实际打印结果不一致现象PrintPreviewDialog预览完美实际打印出来边距不对或者元素超出纸张边界被裁切。原因预览和打印理论上走同一套PrintPage事件结果应该一致。不一致的原因基本都在DefaultPageSettings没有统一打印前改了PaperSize和Margins但创建预览对话框时忘了同步。解决统一用工厂方法创建PrintDocumentprivate PrintDocument CreateDocument() { var doc new PrintDocument(); doc.DefaultPageSettings.PaperSize new PaperSize(Custom, (int)(template.Page.Width / 96f * 100), (int)(template.Page.Height / 96f * 100)); doc.DefaultPageSettings.Margins new Margins( (int)(template.Page.MarginLeft / 96f * 100), (int)(template.Page.MarginRight / 96f * 100), (int)(template.Page.MarginTop / 96f * 100), (int)(template.Page.MarginBottom / 96f * 100)); doc.PrintPage OnTemplatePrintPage; return doc; }预览和实际打印都从CreateDocument()拿实例保证纸张、边距、事件完全一致。这个坑我从预览正常、打印偏了的症状定位到设置没同步整整花了一个下午。6. 进阶占位符替换、批量打印与日志埋点工具跑通之后加上这三个小功能实用性会上一个台阶。6.1 占位符替换除了用DataField直接查字典我还支持模板里写{OrderNo}这种占位符打印时用正则统一替换。这样同一个字段可以嵌入到一段文本里比如把省市区拼成完整地址private string ResolveContent(string raw) { return Regex.Replace(raw, \{(\w)\}, match { var key match.Groups[1].Value; return dataSource.ContainsKey(key) ? dataSource[key] : match.Value; }); }6.2 批量打印的实例管理批量打印时每条数据都new一个PrintDocument不能在循环体外复用同一个实例。PrintDocument内部有状态复用会出现串数据的情况。每次打印前重置分页计数器打印完释放资源批量几百份没有问题。6.3 打印日志与排错打印功能一旦上线反馈过来的问题十有八九是我这个模板打出来不对。没有日志你只能远程让客户截图。我在PrintService里加了一个事件public event Actionstring? PrintLog; private void Log(string message) { PrintLog?.Invoke(${DateTime.Now:HH:mm:ss} {message}); }每次打印记录模板路径、数据条数、纸张尺寸、耗时。文件日志写到本地Logs目录真出了问题看日志比猜快多了。从那以后我每做一个打印工具都会强制把坐标基准96DPI和分页状态必须重置这两行注释写在PrintService类的顶部提醒自己也提醒后来接手的人。像打印这种功能出问题的时候最玄学其实回头一看都是坐标和状态的问题。这个工具从设计器到动态调用全套代码都在包里照着跑一遍比只看文章理解深得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表