news 2026/10/11 2:00:21

DataGridView打印万能模块:GDI+自绘分页缩放与避坑实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DataGridView打印万能模块:GDI+自绘分页缩放与避坑实践

简介:万能打印模块是一套面向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属性,不写死在绘制方法里。这套做法在我手头好几个项目里复用下来了,每次接报表需求都稳,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 1:58:55

2026年10月金山企业销毁怎么选?Top5优缺点推荐

在上海金山工业园区这个地方, 每当一个季度就要结束到了后的时候, 各企业都会碰上同一个非常相同的难题, 就是堆积成山的那些旧档案、那些已经报废物资报废了的设备以及那些废弃的硬盘到底应该采取什么方式来进行处理, 如果把这些东西统统地往纸箱里面塞进去然后就直接扔进垃圾…

作者头像 李华
网站建设 2026/10/11 1:58:35

C++11lambda匿名函数

C11 Lambda&#xff1a;匿名的力量&#xff0c;静默的革命在C漫长的发展历程中&#xff0c;2011年的C11标准犹如一场静默的革命&#xff0c;而lambda表达式正是这场革命中最灵动的篇章。当开发者首次见到[capture](params)->ret{body}这样的语法结构时&#xff0c;他们面对的…

作者头像 李华
网站建设 2026/10/11 1:58:15

关于ClaudeCode中调用的第三方模型非选择模型导致的计费问题

背景 起初&#xff0c;在 Claude Code 中通过 ccswitch 接入了第三方模型&#xff0c;使用一段时间后&#xff0c;在云厂商控制台发现产生了两个模型的计费信息&#xff0c;但实际上只用了一个模型。 原因 在 ccswitch 将 sonnet 挡映射到模型 A&#xff0c;opus 映射到模型 B&…

作者头像 李华
网站建设 2026/10/11 1:58:11

Trunk PVID 和 Hybrid untagged VLAN 的区别

Trunk PVID vs Hybrid untagged VLAN 核心一句话: Trunk:只有 1 个 PVID,这个 VLAN 在出方向剥标签;其余允许 VLAN 全部带标签。 Hybrid:可以同时配置多个 untagged VLAN(出方向剥标签),也可以多个 tagged VLAN,自由度更高。 一、基础概念 Trunk port trunk pvid vla…

作者头像 李华
网站建设 2026/10/11 1:57:04

医疗健康EMBA:投入成本、服务边界与长期合作价值

医疗健康EMBA的价值判断与选择维度 医疗健康领域的管理者在进修选择上&#xff0c;往往面临投入成本、服务边界与长期合作价值之间的权衡。判断是否适合某一项目&#xff0c;不能仅看品牌知名度&#xff0c;而应聚焦于课程设计是否匹配当前的职业阶段与组织需求。本文将从成本、…

作者头像 李华
网站建设 2026/10/11 1:57:03

医疗AI落地实战:用LightGBM+Excel插件实现临床诊断模型快速部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华