
简介万能打印模块是一套面向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只取Visibletrue的列把表头文字、列宽、对齐方式和数据格式化字符串记录下来。格式化这点常被忽略比如日期列在单元格里显示得挺好打印时不带Format就会变成一长串数字用户拿到手上根本看不懂。private ListPrintColumn BuildColumns() { var columns new ListPrintColumn(); 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属性不写死在绘制方法里。这套做法在我手头好几个项目里复用下来了每次接报表需求都稳希望帮到你。本文还有配套的精品资源点击获取