简介:万能打印模块是一套面向C# WinForms开发者的打印功能封装方案,核心解决DataGridView控件数据按指定样式输出到打印纸的问题。资源针对初学者与中级开发者,完整演示如何建立DLL文件,并对DataGridView、PrintDocument、PageSetupDialog、PrintPreviewDialog、PageSettings等关键类进行配置,适合需要在项目中快速集成专业打印能力的场景。压缩包内共45个文件,以11个C#源码文件为主,另含3个可执行程序、3个DLL、3个资源文件及配套数据库MDF/LDF,总大小仅319KB,轻量易学。目录结构清晰,包含DLLFullPrint封装项目与TransferPrint调用示例,并附程序使用说明文档。已有283人学习下载,整套资料将DLL建立、控件设置、页面设置、打印预览和数据库连接串联起来,可直接参考改造,提升打印模块开发效率。
1. 为什么说DataGridView打印是个“万能模块”才值得做
DataGridView在界面上无论怎么排都好看,真到打印这一步,几乎没人是顺的。列宽、行高、分页、边框、中文对齐,每一项都能把打印结果变成一堆没法交付的废纸。所谓“万能打印模块”,通俗讲就是把控件里看到的内容按指定样式送到打印机,表头重复、内容分页、页脚页码全部替你处理掉,不用为每个查询窗口单独写一套打印逻辑。本文适合做桌面管理系统、经常要输出报表的开发者阅读,拿上你现有的DataGridView,按章节一步步改造成“能直接交给用户”的打印稿。核心解决三件事:打印出来的版式可控、业务代码不用重复写、换表格时只改参数不拆逻辑。
2. 核心选型:为什么打印DataGridView要自绘而不是截图
把DataGridView直接塞给打印机,这个动作本身就不存在,控件不是文档,PrintDialog和PrintDocument都不认识它。常见做法有截图位图、转HTML模板、GDI+自绘三条路线,我在实际项目里都试过,最终保留下来且一直在用的是GDI+自绘。原因是它能把“样式”的控制权完全拿回手上,且分页、字体、缩放都是可计算的,而不是靠估计。
2.1 从控件到画布:PrintDocument的工作机制与坐标系
PrintDocument本质是一个“给你一块画布,你负责画完每一页”的事件驱动模型。BeginPrint触发一次做初始化,PrintPage每翻一页触发一次,绘制完成后通过设置 e.HasMorePages 决定要不要继续输出下一页,EndPrint做收尾。这段事件流是所有打印功能的地基,理解了它,后面所有分页和样式代码都只是往PrintPage里填充内容。
这里最容易踩的第一个坑是坐标系。PrintDocument的Graphics默认单位是Display,也就是1/100英寸,而DataGridView的列宽单位是像素。两者不换算直接绘制,打印出来就会出现整体偏移或整表放大缩小。换算本身不复杂,复杂的是“整表宽度超过一页”的情况,必须引入缩放系数。常见做法是先把所有可见列宽累加,得到总像素宽度,再除以96并乘100换算成打印单位,与e.MarginBounds.Width比较,超宽就整体等比缩放。
2.2 三条路线对比:截图、HTML模板、自绘到底怎么选
| 方案 | 样式可控性 | 分页质量 | 字体清晰度 | 维护成本 |
|---|---|---|---|---|
| 截图位图 | 低,打印即图片 | 差,切页难处理 | 低,缩放容易模糊 | 最低,几行代码 |
| HTML模板 | 中,受浏览器渲染影响 | 中,分页不好控 | 中,依赖宿主环境 | 偏高,需要维护模板 |
| GDI+自绘 | 高,每个像素都可控 | 高,分页可精确计算 | 高,按打印机DPI绘制 | 中,核心代码一次性投入 |
截图方案看着省事,实际上打印出来要么比例不对,要么文字发虚,分页只能按固定高度硬切,切到一行文字中间是常事,基本只能用来应付内部临时单据。HTML模板方案在Web端成熟,桌面端控件数据转HTML再调浏览器打印,样式表现受系统和浏览器版本影响,边界情况排查成本高。GDI+自绘的缺点是初期代码量稍大,但换来的是所有列的宽度、行高、边框、颜色、页码全在自己的代码里,出任何问题都能定位到具体一行。
2.3. 准备最小骨架:DataGridViewPrinter的初始化与事件流
先搭出一个能跑通事件流的骨架,再往里面填绘制逻辑。下面这个类就是整个模块的壳,后面几章的所有功能都挂在这几个方法上。
/// 打印辅助类:负责把DataGridView按指定样式输出到打印机 public class DataGridViewPrinter { private readonly DataGridView _grid; // 要打印的数据表格 private int _currentRow; // 当前已打印到的数据行下标 private float _scaleFactor = 1f; // 整表缩放系数,超宽时启用 public DataGridViewPrinter(DataGridView grid) { _grid = grid; } public void Print() { using PrintDocument doc = new PrintDocument(); // 列多的表格建议默认横向,用户也能在对话框里改 doc.DefaultPageSettings.Landscape = true; doc.BeginPrint += OnBeginPrint; doc.PrintPage += OnPrintPage; doc.Print(); // 直接弹出系统打印对话框 } private void OnBeginPrint(object sender, PrintEventArgs e) { _currentRow = 0; // 每次打印任务开始,从第0行重新计数 } private void OnPrintPage(object sender, PrintPageEventArgs e) { // 绘制逻辑在下一章实现,这里先保证能翻页 _currentRow++; e.HasMorePages = _currentRow < _grid.Rows.Count; } }Print方法里的Landscape设置很关键,进销存、订单明细这类宽表格,用横向能少砍至少三分之一的分页量。OnBeginPrint里重置_currentRow是防止同一个实例被连续调用两次时行号错乱,这种问题在用户点了“打印”又取消、再点“打印”时会出现。骨架先跑起来,确认能翻页,再继续做列抽取和样式绘制。
2.2. 把DataGridView变成打印数据:列、行高的抽取与计算规则
3.1 抽取列信息:隐藏列过滤、列宽与格式的读取
绘制之前先把表格结构翻译成打印结构。直接遍历DataGridView的Columns,只取Visible=true的列,把表头文字、列宽、对齐方式和数据格式化字符串记录下来。格式化这点常被忽略,比如日期列在单元格里显示得挺好,打印时不带Format就会变成一长串数字,用户拿到手上根本看不懂。
private List<PrintColumn> BuildColumns() { var columns = new List<PrintColumn>(); foreach (DataGridViewColumn col in _grid.Columns) { if (!col.Visible) continue; // 界面上隐藏的列不参与打印 columns.Add(new PrintColumn { Title = col.HeaderText, Width = col.Width, // 此宽度为像素单位,绘制时换算 Format = col.DefaultCellStyle.Format, // 列太多时,可在后续阶段把冗余列从列表里剔除 }); } return columns; }注意这里读取的是列宽,不是单元格宽度。冻结列场景下列宽和Cells的Width会有偏差,直接用Cells的Bounds做打印布局会错位。BuildColumns返回的列表就是后续分页和绘制的唯一依据,新增列、调整列顺序都不用改打印代码,重新绑定DataGridView后重新取一遍即可。
3.2 分页计算:整体缩放、行高测量、每页行数
分页是打印模块最需要扣细节的地方。分页计算要做三件事:判断整表宽度是否超宽并计算缩放系数、测量每条数据行的实际高度、算出当前页能放多少行。行高不能用固定值,中文在不同字号下的实际渲染高度和字体度量有关,固定行高必然出现截字。
private void CalculatePageLayout(Graphics g, RectangleF bounds) { // 1. 整表宽度超宽时,等比缩放到刚好放得下 float totalWidth = _columns.Sum(c => c.Width); if (totalWidth > bounds.Width) { _scaleFactor = bounds.Width / totalWidth; } // 2. 用默认字体先测一行文本高度,加上下边距作为行高 float rowHeight = g.MeasureString("测试中文行高", _rowFont).Height + 6f; // 3. 一页可用高度 = 页面边界高度 - 表头区 - 单据标题区 - 页脚区 float usableHeight = bounds.Height - _titleHeight - _columnHeaderHeight - _footerHeight; _rowsPerPage = (int)(usableHeight / rowHeight); _rowHeight = rowHeight; }g.MeasureString是这里的关键,它返回的是实际渲染高度,而不是字体字号的理论行高。6f的边距是上下各3像素,文字紧贴边框时打印出来会很难看。_rowsPerPage只是估算值,实际绘制时如果某一行因为内容超长换行变成两行高,这一页能放的行数就会少一行,所以后面绘制循环里要以实际累加高度为准,而不是死板地按_rowsPerPage切页。缩放系数只影响绘制,不改动DataGridView自身的值,这个设计能保证打印模块对原业务无侵入。
3.3 指定样式绘制:表头、数据行、边框、斑马纹和图片列
绘制部分就是PrintPage里的核心逻辑,也是“指定样式”四个字真正落地的地方。完整绘制顺序是:单据标题、列标题、数据行、页脚页码。每页都要重画一次单据标题和列标题,才能保证第二页、第三页拿起来就知道是什么表。
private void OnPrintPage(object sender, PrintPageEventArgs e) { Graphics g = e.Graphics; RectangleF bounds = e.MarginBounds; // 第一页画单据标题,后续页直接画列标题 if (_currentRow == 0) { DrawTitle(g, bounds); } DrawColumnHeaders(g, bounds); // 每页重复绘制列标题 float y = bounds.Top + _columnHeaderHeight; int rowPrinted = 0; while (_currentRow < _grid.Rows.Count && rowPrinted < _rowsPerPage) { DataGridViewRow row = _grid.Rows[_currentRow]; if (row.IsNewRow) { _currentRow++; continue; } // 跳过编辑新行 float rowHeight = _rowHeight; DrawDataRow(g, bounds, row, ref y, ref rowHeight); y += rowHeight; rowPrinted++; // 实际打印行数累加,防止估算偏差 _currentRow++; } DrawFooter(g, bounds, _currentRow); // 页脚带页码 bool hasMore = _currentRow < _grid.Rows.Count; e.HasMorePages = hasMore; }while循环比for循环更适合这里,因为每行高度可能有变化,用累计y坐标判断是否超出边界最可靠。row.IsNewRow这个判断就是在处理DataGridView默认的“添加新行”空行,不跳过它,打印件最后会多出一张只有表头没有数据的空行。每一页都独立计算实际可容纳行数,靠rowPrinted计数,不依赖之前的估算值,这样即使某行换行变高,也不会出现空白页。
private void DrawDataRow(Graphics g, RectangleF bounds, DataGridViewRow row, ref float y, ref float rowHeight) { float x = bounds.Left; // 斑马纹:偶数行铺一层浅灰背景,奇数行保持白色,提升长表格可读性 if ((_currentRow & 1) == 0) { using Brush zebra = new SolidBrush(Color.FromArgb(240, 242, 245)); g.FillRectangle(zebra, x, y, bounds.Width, rowHeight); } // 逐列绘制单元格内容 for (int i = 0; i < _columns.Count; i++) { PrintColumn col = _columns[i]; float colWidth = col.Width * _scaleFactor; RectangleF cellRect = new RectangleF(x, y, colWidth, rowHeight); object value = row.Cells[i].Value; string text = value?.ToString() ?? ""; g.DrawRectangle(Pens.Gray, cellRect.X, cellRect.Y, cellRect.Width, cellRect.Height); // 图片列特殊处理:用DrawImage画缩略图,不画文本 if (value is Image img) { g.DrawImage(img, cellRect.X + 2, cellRect.Y + 2, cellRect.Width - 4, cellRect.Height - 4); } else { g.DrawString(text, _cellFont, Brushes.Black, cellRect, _cellFormat); } x += colWidth; } }单元格的绘制顺序是先画边框再画内容,边框用Pen统一颜色,内容用DrawString带格式。_cellFormat里设置对齐方式、自动换行和修剪方式,中文超长时选择Clip而不是Ellipsis,打印单据场景下用户更希望看到完整内容而不是省略号,或者启用自动换行让行高动态变化。图片列的DrawImage不是必须的,如果业务里没有图片展示列可以去掉,保留它只是为了让模块在不同项目里复用时不改结构。
3.4 组装入口:打印预览、直接打印、页边距调整一次搞定
绘制和分页都齐了,最后提供一个统一的公开入口。常见做法是Print()和Preview()两个方法,内部用同一个PrintDocument实例,保证预览和实际打印效果完全一致。
public void Print() { using var doc = CreateDocument(); doc.Print(); } public void Preview() { using var doc = CreateDocument(); using PrintPreviewDialog dialog = new PrintPreviewDialog(); dialog.Document = doc; dialog.ShowDialog(); // 先看效果再走打印,省纸省心 } private PrintDocument CreateDocument() { var doc = new PrintDocument(); doc.DefaultPageSettings.Landscape = _columns.Sum(c => c.Width) > 800; doc.DefaultPageSettings.Margins = new Margins(50, 50, 80, 80); doc.BeginPrint += OnBeginPrint; doc.PrintPage += OnPrintPage; return doc; }调用端的使用方式简化为:new一个DataGridViewPrinter传进表格,然后选择Print或者Preview。这就是“万能”两个字的落点,不管你的业务窗口是订单列表、库存清单还是人员花名册,只要数据已经绑定到DataGridView,打印模块就能直接接管。CreateDocument把页边距统一设置为50单位,标题区的80留给单据标题,不压着页边框,打印出来上下留白更自然。
4. DataGridView打印避坑:五个高频问题排查记录
下面五个问题是我在多个项目里反复遇到过的,按现象、原因、处理顺序写清楚。建议先把这页截图存下来,打印功能联调时逐个对照排查,能省掉大半调试时间。
4.1 整表缩放失真,最后一列被截掉或留大片空白
现象:屏幕上看表格刚刚好,打印出来整体比原来大一截,最后一列直接出了纸面,或者是表格窄窄地缩在纸左侧,右边空出大块。
原因:Graphics坐标系默认是1/100英寸,而DataGridView列宽是像素,直接拿像素宽度当打印宽度用,96像素在打印机上并不等于1英寸,整体就被放大了。
处理:在打印前先把所有列宽乘以96再除以100,或者直接乘以g.DpiX / 96f折算成打印单位。如果折算后总宽度仍然大于e.MarginBounds.Width,就整体等比缩放,缩放系数乘到每个列的宽度和字体大小上。最容易忽略的是字号也要跟着缩放,只缩放列宽不缩放字体,表头和内容会溢出单元格。
4.2 中文和长文本被截断,半个字压在边框线上
现象:单元格里的中文右半边被切掉,或者文字上下方向只显示了一半,第一行和第二行重叠。
原因:行高用了固定数值,而GDI+渲染中文时实际字体度量比想象中的大,特别是宋体、微软雅黑在小字号下仍有余量,固定行高必然容纳不下。
处理:每页开始前用g.MeasureString测量一行测试文本的实际高度,加上6像素上下边距作为行高。注意这个测量必须使用和实际绘制相同的Graphics对象和字体,不同DPI下测量结果不同。如果一行数据里出现过长的编号或备注,用StringFormat把换行打开,并在绘制循环里用累加高度判断本页是否放得下,放不下就翻页。
4.3 预览末尾多出一整张空白页
现象:数据明明只有两页多几行,打印出来却是三页完整的纸,最后一页只有一个表头,底下全是空白。
原因:HasMorePages的设置时机不对。常见写法是先判断再翻页,最后一页打印完数据后_currentRow刚好等于Rows.Count,但在某些分支里提前把HasMorePages设成了true,或者判断条件用了大于号,导致系统再多问一次页码。
处理:把_currentRow的递增放在所有绘制完成之后,然后统一用e.HasMorePages = _currentRow < _grid.Rows.Count;收尾。这里千万不要在绘制过程中间提前设置HasMorePages,所有分支都要走到最后一行赋值。调试时可以临时把EmptyPage这个事件挂上看一眼,能直观看到系统到底多要了几次页。
4.4 数据末尾多打了一个空行
现象:数据行全打完了,最后面还跟着一行只有边框、没有任何内容的空行,用户拿到单据以为漏了数据。
原因:DataGridView在AllowUserToAddRows为true时,Rows集合末尾包含一个IsNewRow为true的新行占位,界面里显示为带星号的空白输入行,打印遍历Rows集合时把它也当成了数据行。
处理:在绘制循环里加一行判断,if (row.IsNewRow) continue;。不要用删除的方式去清掉这个占位行,删除会影响用户界面上的新增功能。以前遇到过用Rows.Remove处理占位行的方案,触发了绑定数据源的异常,得不偿失。
4.5 按钮列和合并单元格打出来是空白
现象:界面上该显示的按钮文字、链接文字,打印出来那一列是空的;合并单元格区域中只有第一个格子有内容,其他格子是空白。
原因:DataGridViewButtonColumn这类控件列在整体控件中是指纹档的焦点交互列,打印时Graphics.DrawString不会主动去绘制其他控件的样式。合并单元格是DataGridView自己按绘制效果模拟的,底层每个Cell对象仍然是独立的,直接取Cells[i].Value时合并区域的非起始单元格拿到的值是null。
处理:绘制单元格时先判断列类型,DataGridViewButtonColumn、DataGridViewLinkColumn都按普通文本绘制,按钮边框不画,只画文字。合并单元格要在抽取列信息时额外记录合并跨度,绘制到跨度的起始单元格时计算合并区域范围并整块绘制,被合并覆盖的其他单元格跳过边框和内容,这样才能保持打印件和屏幕显示一致。
5. 进阶:把打印模块做成可配置的复用件
打印模块稳定之后,全部参数继续硬编码在类里面,换一个项目又要从源码里翻出字号、颜色、边距慢慢改。花一两个小时把这几个零散参数收敛成一个Options对象,之后每个窗口的调用代码会短到难以置信。
public class PrintOptions { public string Title { get; set; } = ""; // 单据标题文字 public Font TitleFont { get; set; } // 标题字体 public Font CellFont { get; set; } // 单元格字体 public float RowPadding { get; set; } = 6f; // 行上下边距 public bool ZebraStripe { get; set; } = true; // 是否打印斑马纹 public bool ShowRowNumber { get; set; } = true; // 是否展示行号列 public bool RepeatHeaderOnEachPage { get; set; } = true; // 每页重复表头 }Options对象建议在DataGridViewPrinter的构造函数传入或提供属性赋值,默认值保证不传也能跑。这样不同业务窗口只要各自new一份Options,订单窗口把Title设为“订单明细表”,库存窗口设为“库存盘点表”,字体大小按纸张大小调整即可,绘制方法不用动一行代码。
另一个值得顺手做的事是把PrintPreviewDialog作为所有打印入口的必经之路。我一般会在Print方法里默认弹预览对话框,用户确认后再走真正的doc.Print(),而不是直接送打印机。预览和实际打印共用同一个PrintDocument实例,能保证效果一致,也能拦截大部分版式问题。
一个落地的复用习惯是:凡是用到打印模块的窗口,统一采用“先绑定DataGridView,再调printer.Print()”的结构,业务代码里不允许出现任何Graphics或坐标数值。这样后续换主题、统一页眉页脚时,只需要改打印模块内部实现,所有调用方自动生效。我最早做某进销存项目的打印功能时,被单位换算坑了一整天,后来养成两个习惯:任何打印功能先跑预览,翻到第一页和最后一页对比确认分页;任何样式数值都抽成Options属性,不写死在绘制方法里。这套做法在我手头好几个项目里复用下来了,每次接报表需求都稳,希望帮到你。
本文还有配套的精品资源,点击获取