ARTICLE DETAIL

资讯详情

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

WinForms自定义打印设计:从XML模板到PrintDocument实战

WinForms自定义打印设计:从XML模板到PrintDocument实战 简介面向需要在 Windows 窗体Winform应用中集成自定义打印功能的开发人员这是一份完整的打印设计工具实现方案。项目基于 C# 与 .NET 框架以打印文档对象为核心入口结合绘图对象实现文本、图片、二维码等元素的绘制与输出包含可视化设计界面、打印预览窗口、逐页打印事件处理逻辑以及按钮触发打印的完整控制代码并针对动态文本与二维码场景给出可运行的示例。压缩包共 563 个文件大小 56.26MB主要文件类型包括程序集文件dll、源代码文件cs、配置标记xml、结构化数据json与调试符号pdb同时附带解决方案工程和可执行程序方便直接打开工程查看或集成复用。当前已有 3713 人学习下载无论用于学习打印组件机制还是作为生产环境中的自定义打印设计器参考它都能提供清晰的代码结构和具体排错思路。1. 自定义打印设计工具解决的不只是打印先从一次打印需求说起接到一个打印需求时你第一反应往往是改代码加一个字段、挪一下坐标、把 A4 换成长纸条然后重新发布一版。等业务人员下次提“打印格式我们自己排”这套路就撑不住了。winform 自定义打印设计工具就是把“打印格式”从代码里抽出来交给用户自己画设计器里拖控件、存模板运行时把数据填进模板调用 Windows 打印组件输出。适合正在做 ERP、进销存、MES 这类 C# 桌面应用、被单据格式变更缠住的开发者。但我的建议是别急着写打印代码先花半天把模板的数据模型定明白后面所有事情都会顺很多。2. 先定义模板模型XML 结构、控件类型与绑定表达式的约定工具的本质是“可视化编辑模板”所以第一步不是画界面而是定一套模板存储结构。常见做法是把模板存成 XML可读、可 diff、可做版本迁移C# 里用 XmlSerializer 或 XDocument 都能直接读写出问题也容易排查。我一般会用一个最小模板把结构立住后面设计器和打印引擎都围绕它来写。?xml version1.0 encodingutf-8? Template Version1.0 PaperNameA4 PaperWidth8050 PaperHeight11690 MarginLeft60 MarginRight60 MarginTop60 MarginBottom60 Items TextItem NamelblTitle Label公司名称 Rect X300 Y240 Width4000 Height500/ Font Name微软雅黑 Size16 BoldTrue/ Binding{Field:CompanyName}/Binding Align CenterTrue/ /TextItem LineItem NamelineHeader Label表头横线 Rect X300 Y780 Width7200 Height1/ Pen Width0.5 Color#000000/ /LineItem TableItem NametblDetail Label明细列表 Rect X300 Y900 Width7200 Height0/ Columns Column FieldProductName Width2400 Header商品名称/ Column FieldQuantity Width1200 Header数量/ Column FieldPrice Width1200 Header单价/ Column FieldAmount Width2400 Header金额/ /Columns Binding{Table:Items}/Binding RowHeight360/RowHeight /TableItem /Items /Template这份 XML 里纸张宽高用的是 1/100 英寸A4 宽 8.27 英寸记作 827实际存 8050 是因为包含了边距这个单位后面和GraphicsUnit.Display一一对应打印时不用做任何缩放。每个控件都有 Name、Label、Rect、Font、Binding 这几个公共属性不同类型再补自己的专属属性比如 LineItem 的 Pen、TableItem 的 Columns。这样设计器加载模板时可以按 Name 找到控件打印引擎也可以按 Name 做数据字段映射。2.1 为什么模板用 XML 而不是 JSON 或代码里写死做 WinForms 的开发者往往习惯把打印内容直接写在 PrintPage 事件里模板一改代码就翻车。XML 模板的核心价值是把“格式”和“逻辑”分开格式进文件逻辑只负责把数据源塞进去。相比 JSONXML 在 .NET 里天然有 XPath、XmlSerializer 这类现成工具模板里做字段替换也顺手而且老业务系统里导出 Excel、导入配置都是 XML 格式团队接受成本低。另一个理由是模板文件需要被业务人员手工修改XML 的注释和排版比 JSON 更友好。2.2 绑定表达式让模板和数据源解耦绑定表达式是整个模板模型的心脏。我用的是两套绑定{Field:字段名}做单值替换{Table:表名}做明细列表区域。单值替换适用于公司名称、单号、日期这类字段打印时直接在数据源字典里按字段名取值列表区域适用于订单明细、入库清单这类多行数据打印引擎会按 TableItem 的 Rect 作为起始区域逐行绘制行高超出页面底部时自动开新页。字段名建议和数据库列名保持一致减少报表期的映射工作。2.3 版本号是给未来留的后悔药模板一定会变今天坐标单位是 1/100 英寸明天可能想改成毫米今天绑定表达式是{Field}明天可能要支持函数。所以在 Template 上留一个 Version 属性加载时按主版本号分派解析逻辑。旧模板不做强制转换而是由迁移函数在内存中重写后再绘制这样老模板文件还能留着新代码也不会被历史包袱卡住。这个小设计看着多余等到要支持多个客户的不同模板时就能体会到它的价值。3. 设计器从零搭画布、拖拽、属性面板三步走模板模型立住之后设计器其实就是一个“画 XML 的 WYSIWYG 编辑器”。我按三个模块来做左侧控件箱、中间设计画布、右侧属性面板。界面美化不用在这一步纠结先把交互跑通后面再用 WinForms 自带的 TableLayoutPanel 和自定义绘制把观感调上去。3.1 主窗体布局控件箱、画布、属性面板的三栏结构public partial class TemplateDesignerForm : Form { private ListBox _toolbox; private TemplateCanvas _canvas; private PropertyGrid _propertyGrid; public TemplateDesignerForm() { InitializeComponent(); _toolbox new ListBox { Dock DockStyle.Left, Width 180 }; _toolbox.Items.AddRange(new object[] { 文本, 矩形, 线条, 图片, 表格 }); _toolbox.MouseDown Toolbox_MouseDown; _canvas new TemplateCanvas { Dock DockStyle.Fill }; _propertyGrid new PropertyGrid { Dock DockStyle.Right, Width 260 }; Controls.Add(_canvas); Controls.Add(_propertyGrid); Controls.Add(_toolbox); } }这段代码做的事情很简单左边用一个 ListBox 当控件箱中间用自定义的 TemplateCanvas 继承 Panel 做画布右边用 WinForms 自带的 PropertyGrid 显示选中控件的属性。PropertyGrid 是现成的控件支持反射编辑能省掉自己写属性表单的大量工作。Toolbox_MouseDown 里记录当前选中的控件类型画布的 MouseDown 事件再决定是新建控件、选中已有控件还是开始拖拽。3.2 鼠标拖拽添加与缩放像素坐标和模板坐标的换算private float PixelsToDisplayUnits(float px, float dpi) { return px * 100f / dpi; } private void TemplateCanvas_MouseMove(object sender, MouseEventArgs e) { if (_dragState DragState.Moving) { float deltaX PixelsToDisplayUnits(e.X - _startPoint.X, _dpi); float deltaY PixelsToDisplayUnits(e.Y - _startPoint.Y, _dpi); _selectedItem.Rect new RectangleF( _startRect.X deltaX, _startRect.Y deltaY, _startRect.Width, _startRect.Height); _startPoint e.Location; Invalidate(); } else if (_dragState DragState.Resizing) { float newWidth PixelsToDisplayUnits(e.X - _startPoint.X, _dpi); float newHeight PixelsToDisplayUnits(e.Y - _startPoint.Y, _dpi); _selectedItem.Rect new RectangleF( _startRect.X, _startRect.Y, Math.Max(50, _startRect.Width newWidth), Math.Max(20, _startRect.Height newHeight)); Invalidate(); } }这里的核心逻辑是单位换算。模板里坐标的单位是 1/100 英寸而鼠标事件给的是像素必须用100 / Dpi做系数转换否则在 96 DPI 的屏幕上画着刚好到 125% 缩放的机器上就偏了。_startRect记录拖拽开始时的原始矩形拖拽过程中只累加增量避免误差累积。Resizing 时给最小宽高做约束防止把控件拖成一条看不见的细线。画布的 Paint 事件里按模板的 Rect 逐一绘制控件并且把选中控件的边框画成虚线加四个缩放手柄。3.3 属性面板用反射统一读控件属性而不是每个类型写一套表单private void TemplateCanvas_SelectedItemChanged(object sender, EventArgs e) { _propertyGrid.SelectedObject new TemplateItemWrapper(_selectedItem); } public class TemplateItemWrapper { private readonly TemplateItem _item; public TemplateItemWrapper(TemplateItem item) { _item item; } [Category(布局)] public float X { get _item.Rect.X; set { _item.Rect.X value; } } [Category(布局)] public float Y { get _item.Rect.Y; set { _item.Rect.Y value; } } [Category(布局)] public float Width { get _item.Rect.Width; set { _item.Rect.Width value; } } [Category(外观)] public string FontName { get _item.Font.Name; set { _item.Font.Name value; } } [Category(数据)] public string Binding { get _item.Binding; set { _item.Binding value; } } }直接暴露 TemplateItem 给 PropertyGrid 会带出一堆不需要的内部属性所以用 TemplateItemWrapper 包一层把 X、Y、Width、Height、字体、绑定表达式这些业务字段通过 Category 属性分组。PropertyGrid 会根据特性自动生成分组显示不用自己画编辑器。这段代码的关键价值是新增一种控件类型时只要 TemplateItem 基类有这些属性属性面板代码一行都不用改这就是“用反射代替硬编码”的典型收益。4. 打印引擎与代码动态调用一份模板两处绘制的实现设计器能存模板只是完成一半另一半是运行时把模板真正打到纸上。这里我采用“一份绘制逻辑两处调用”的结构设计器画布用一个 RenderPage 函数渲染打印时也调用同一个函数只是 Graphics 来源不同。这样保证预览和打印的格式永远不会出现“屏幕上看着对打出来就错位”的问题。4.1 打印组件选型PrintDocument 是 WinForms 下最稳的入口Windows 打印组件里最常用的是System.Drawing.Printing.PrintDocument。虽然 WPF 的 XPS 打印看起来更现代但 WinForms 项目里 PrintDocument 和 GDI 的配合最成熟支持针式打印机、自定义纸张、逐份打印而且老设备驱动的兼容性经过大量生产环境验证。如果项目要兼容 Windows 7 到 Windows 11PrintDocument 是风险最低的选择。PrintDocument 的核心是 PrintPage 事件它在每一页打印前触发你在这里用 Graphics 绘制当前页的内容然后通过HasMorePages决定是否继续。4.2 动态调用打印数据源替换与打印参数注入public void PrintTemplate(TemplateDocument tpl, PrintData data, string printerName null) { using (var pd new PrintDocument()) { if (!string.IsNullOrEmpty(printerName)) { pd.PrinterSettings.PrinterName printerName; } PaperSize paper new PaperSize(tpl.PaperName, (int)(tpl.PaperWidth / 100f * 1000), (int)(tpl.PaperHeight / 100f * 1000)); pd.DefaultPageSettings.PaperSize paper; pd.DefaultPageSettings.Margins new Margins( tpl.MarginLeft, tpl.MarginRight, tpl.MarginTop, tpl.MarginBottom); int pageIndex 0; pd.PrintPage (s, e) { bool hasMore RenderPage(e.Graphics, tpl, data, pageIndex); pageIndex; e.HasMorePages hasMore; }; try { pd.Print(); } catch (InvalidPrinterException ex) { MessageBox.Show(打印机不可用 ex.Message); } } }这段代码有四个关键参数PrinterName 指定输出打印机留空则用系统默认打印机PaperSize 要按 1/100 英寸转成打印机的 1/1000 英寸单位注意这里乘了 1000 而不是 100因为 PaperSize 构造函数用的是千分之一英寸Margins 控制页边距必须和模板里的边距一致否则打印内容会偏移HasMorePages 是分页的开关返回 true 就会继续触发下一次 PrintPage。用 using 包裹 PrintDocument 是必须的因为 Windows GDI 句柄不及时释放连续打印几十单后会报“内存不足”。4.3 RenderPage 绘制函数把模板控件画到任意 Graphics 上private bool RenderPage(Graphics g, TemplateDocument tpl, PrintData data, int pageIndex) { g.PageUnit GraphicsUnit.Display; // 1/100 英寸与模板单位一致 int currentPage 0; bool hasMore false; float cursorY 0; foreach (var item in tpl.Items) { if (item is TextItem text) { string content data.Fields[text.Binding]; using (var font new Font(text.Font.Name, text.Font.Size, text.Font.Bold ? FontStyle.Bold : FontStyle.Regular)) { SizeF size g.MeasureString(content, font); float x item.Rect.X; if (text.Align.Center) x (item.Rect.Width - size.Width) / 2f; g.DrawString(content, font, Brushes.Black, x, item.Rect.Y); } } else if (item is TableItem table) { var rows data.Tables[table.Binding]; cursorY table.Rect.Y; for (int i 0; i rows.Count; i) { foreach (var col in table.Columns) { g.DrawString(rows[i][col.Field].ToString(), NormalFont, Brushes.Black, table.Rect.X col.Offset, cursorY); } cursorY table.RowHeight; if (cursorY tpl.PaperHeight - tpl.MarginBottom) { hasMore true; return hasMore; } } } } return hasMore; }这个函数的要害是g.PageUnit GraphicsUnit.Display。设置之后所有 DrawString 的坐标都按 1/100 英寸解释模板里的 Rect 无需换算直接使用。TextItem 绘制时用 MeasureString 先量出文本宽度再按 Center 属性计算水平居中偏移TableItem 绘制时用 cursorY 记录当前行 Y 坐标每画一行加 RowHeight超过纸张底线就设 HasMorePages 为 true后续页从断点继续。表格列偏移col.Offset是在加载模板时累计计算好的避免每行都重复算列宽。参数调优的几个必调项字体大小建议用磅值和 Word 的习惯一致不要用像素行高要有最小限制防止字体变大后文字重叠打印图片控件前先做解码压缩不然一张 5MB 的 JPG 会拖慢整个打印任务。这些参数最初可能靠经验跑几轮真实单据后就能固化到模板默认值里。4.4 打印任务参数速查表参数所在位置单位建议值说明PaperSizeDefaultPageSettings1/1000 英寸按模板指定与打印机驱动支持的纸张匹配MarginsDefaultPageSettings1/100 英寸60各边同一值避免内容偏移PrinterNamePrinterSettings字符串系统默认指定前先用 InstalledPrinters 校验CopiesPrinterSettings整数1多联单据按需设置PageUnitGraphics枚举Display与模板坐标单位一致字体 SizeFont磅9-16针式打印机建议不低于 9 磅5. 避坑清单预览对不齐、针式打印机偏移、中文乱码与打印假死任何打印功能都要经历真实打印机的毒打。下面五条是 WinForms 打印组件最常见的坑每一条都是现场真实翻车记录按“现象 → 原因 → 解决”写清楚遇到可直接对照排查。5.1 设计器里看着居中打印出来整体往右下偏现象模板在设计画布上完美居中打出来内容整体偏移到右下角越靠近纸张边缘偏移越明显。原因默认情况下 PrintPageEventArgs.Graphics 的原点在可打印区域左上角而不是纸张左上角不同打印机驱动对“可打印区域”的解释不同驱动报告的 PrintableArea 往往比实际纸张小一圈。解决把模板内容整体按可打印区域原点和纸张原点之间的差值做一次平移公式是g.TranslateTransform(-e.PageSettings.PrintableArea.X, -e.PageSettings.PrintableArea.Y)这个值在 PrintPage 事件里通过e.PageSettings.PrintableArea读取而不是自己硬编码。5.2 针式打印机连续打印几十单后越来越错位现象针式打印机打印到第二联、第三联时行位置逐步偏移最后整个表格错开。原因针式打印机是走纸机构每页连续进纸都有累计误差如果代码里每次都重新设置 PaperSize 和 Margins驱动会做额外的纸张调整放大误差。解决打印任务开始前把 PaperSize、Margins、PrinterSettings 全部一次性设置好PrintPage 事件里只画内容不再碰任何页面设置对于连续纸要确认驱动中纸张规格选择为“连续进纸”而不是“A4 单页”。这类问题在高速针打上尤其明显必要时为特定机型写补偿参数表。5.3 中文打印出来是方块或者字体变成了宋体现象模板里指定微软雅黑屏幕上看着没问题打印机输出却变成丑丑的宋体甚至个别字符是方块。原因针式打印机或低端激光打印机的字库只带中文字体子集GDI 会做字体回退另外 System.Drawing.Font 在创建字体时不会校验该字体在打印机上是否存在打印驱动就会用默认字体硬上。解决模板字体优先使用宋体、黑体这类全平台字体如果一定要用微软雅黑打印前用 InstalledFontCollection 检查字体是否在系统里不存在就回退到宋体。注意不要用“嵌入字体”这个伪方案它会让打印任务变慢还涉及字体版权问题。5.4 PrintDocument.Print() 抛出 E_PENDING 或 InvalidOperationException现象程序在调用pd.Print()的瞬间异常退出异常信息是 E_PENDING 或“操作无效因为打印机未安装或当前打印机不可用”。原因PrintDocument 对象被提前垃圾回收或者 PrinterSettings 里的 PrinterName 指向一个已删除的网络打印机还有一种常见情况是打印逻辑放在后台线程里而 PrinterSettings 在 UI 线程初始化跨线程访问导致状态错乱。解决用 using 包裹 PrintDocument 并且把打印调用放在 UI 线程的同步方法里PrinterName 在设置前用PrinterSettings.InstalledPrinters校验一遍。后台打印可以采用 PrintDocument.PrintController但不要直接跨线程访问 PrinterSettings。5.5 模板改完代码里能读到打印时却是旧内容现象用户在设计器里保存模板后立刻打印结果打印的还是保存前的旧内容。原因代码里缓存了模板对象的实例保存动作只更新了磁盘文件内存中的 TemplateDocument 没有重新加载。解决用 FileSystemWatcher 监听模板文件的 LastWriteTime打印前比较文件时间戳变化则重新加载 XML。FileSystemWatcher 的回调发生在后台线程加载完要通过 Invoke 回到 UI 线程再刷新画布否则界面不刷新用户以为是没生效。6. 进阶验证把打印结果转成图片做回归模板热加载与自动修正功能能跑通之后要做回归验证。我的习惯是给 RenderPage 写一个导图入口既然打印和导图共用同一个绘制函数我只要把 Graphics 换成内存 Bitmap 的 Graphics就能把每一页内容导出成 PNG。这样改完模板后不需要真去打纸直接跑脚本把几十个历史模板全部渲染成图片跟基线对比一轮基本能挡住八成回归问题。public void ExportTemplateToPng(TemplateDocument tpl, PrintData data, string outputDir, int dpi 300) { int width (int)(tpl.PaperWidth / 100f * dpi); int height (int)(tpl.PaperHeight / 100f * dpi); using (var bmp new Bitmap(width, height)) using (var g Graphics.FromImage(bmp)) { g.PageUnit GraphicsUnit.Pixel; float scale dpi / 100f; g.ScaleTransform(scale, scale); RenderPage(g, tpl, data, 0); bmp.Save(Path.Combine(outputDir, ${tpl.Name}_page0.png), System.Drawing.Imaging.ImageFormat.Png); } }这段代码里的 dpi 参数决定导出图片的清晰度300 DPI 足够肉眼检查也适合做像素级 diff。渲染时用 ScaleTransform 把模板的 Display 单位放大到目标分辨率这样导出的 PNG 和真实打印出来的物理尺寸保持一致可以用来核对模板里每个控件的相对位置。加上模板热加载那一套完整的回归路径是改模板 → 脚本导出 PNG → 和基线图做 diff → 通过后打印少量纸张做人工终检。我现在做打印模块都强制保留几个基线模板谁改了模板、谁让哪里偏移了一跑 diff 就现形。模板版本自动修正的秘密也在版本号。旧模板文件里坐标单位如果和新版本不一致加载逻辑读 Version 之后走一次把旧坐标换算成新单位的迁移函数不会让老模板直接爆炸。这个思路扩展到字体替换、纸张规格变化同样适用。从需求出现到工具落地核心就一句话模板模型是根双 Graphics 调用是干避坑才有得活。希望这些经验能让你少打几十张废纸也希望帮到你的是那个“先定义模板再写代码”的习惯。本文还有配套的精品资源点击获取
返回列表