☰
WinForms DataGridView打印模块:高保真样式还原与智能分页
2026/10/8 2:22:05 网站建设 项目流程

简介:这是一套面向C# WinForm开发者的通用打印功能封装方案,聚焦于将DataGridView控件中的数据按自定义样式高效输出至打印机或预览窗口,适用于报表导出、业务单据打印等实际应用场景。资源包共45个文件,包含11个核心C#源码文件(如PrintClass.cs、Frm_PrintSet.cs等)、3个可执行exe程序、3个动态链接库dll、4个说明类txt文档及1个详细使用手册doc,辅以sln工程文件、csproj项目配置和数据库文件(mdf/ldf),整体压缩后仅319KB,结构完整、即开即用。已有283人学习下载,开发者可直接复用DLL模块集成到自有系统,快速实现打印设置、页面布局、预览对话框与打印文档类(PrintDocument)的全流程配置,同时掌握PageSetupDialog、PrintPreviewDialog及PageSettings等关键组件的协同调用逻辑。

1. 为什么 DataGridView 打印总像在“猜谜”:样式错位、分页断行、字体飞走?这个万能打印模块真能一招封神?

你有没有试过把 WinForms 里一个精心排版的 DataGridView 直接丢给PrintDocument——结果标题栏跑到了第二页中间,合并单元格被撕成三块,红色警告色变成灰扑扑的马赛克,连“合计行”都印在了页脚空白处?这不是你的代码写错了,而是 WinForms 原生打印机制根本没把 DataGridView 当“活表格”看:它只认像素坐标,不认逻辑结构;只管画线填色,不管语义层级。所谓“万能打印模块”,不是靠魔法,而是用一套可复用、可配置、可调试的渲染代理层,把 DataGridView 的数据流、样式规则、布局约束,全部翻译成Graphics对象能听懂的绘图指令。它不替换PrintDocument,而是在其PrintPage事件里接管绘制权;不硬编码字体字号,而是从控件实时读取DefaultCellStyle、ColumnHeadersDefaultCellStyle、RowTemplate.Height等真实状态;更关键的是——它把“分页”从玄学变成可预判的数学问题:每页最多容纳多少行?页眉页脚占几像素?跨页合并单元格怎么续打?本模块就是为解决这三类高频翻车场景而生:需要保留原表视觉一致性(含颜色/字体/对齐/边框)的正式报表输出;需支持 A4/A3/自定义纸张+横向/纵向+页边距灵活配置的行政文档;以及要求带页码、公司 Logo、打印时间戳、页眉页脚动态内容的合规性交付物。适合 WinForms 中级开发者——你已会绑数据、设样式,但不想再为每次改个列宽就重写一遍打印逻辑。


2. 从零构建打印代理层:用 Graphics + RectangleF 搭建可控渲染流水线

2.1 核心设计哲学:不复制控件,只镜像状态

万能打印模块的本质,是建立一个DataGridView 状态快照 → 可绘制对象 → 分页渲染指令的单向流水线。它绝不调用DrawToBitmap()(失真严重、不支持透明、无法控制 DPI),也不依赖第三方 PDF 库(增加部署复杂度)。真正可靠的做法,是让模块在PrintPage事件中,用e.Graphics直接绘制:

  • 先计算整个表格在打印区域内的逻辑尺寸(非屏幕像素,而是按打印机 DPI 换算后的RectangleF);
  • 再将 DataGridView 的Columns,Rows,DefaultCellStyle等属性,逐项映射为Brush,Pen,Font,StringFormat实例;
  • 最后按行、按列遍历,用Graphics.DrawString(),Graphics.FillRectangle(),Graphics.DrawRectangle()精确落笔。

这种做法牺牲了一点开发速度,换来的是100% 可控性:你能决定每一像素画什么,也能在任意位置插入 Logo 或水印,还能在跨页时主动截断并补全表头。

2.2 关键类结构与初始化入口

模块主体封装为DataGridViewPrinter类,构造函数仅接收DataGridView实例和PrintDocument实例:

public class DataGridViewPrinter { private readonly DataGridView _grid; private readonly PrintDocument _printDoc; private readonly List<PrintPageInfo> _pageInfos = new(); public DataGridViewPrinter(DataGridView grid, PrintDocument printDoc) { _grid = grid ?? throw new ArgumentNullException(nameof(grid)); _printDoc = printDoc ?? throw new ArgumentNullException(nameof(printDoc)); // 绑定事件,但不自动触发打印 _printDoc.PrintPage += OnPrintPage; } private void OnPrintPage(object sender, PrintPageEventArgs e) { // 主渲染逻辑入口,见 2.3 节 RenderPage(e.Graphics, e.MarginBounds, e.PageSettings); } }

提示:_printDoc必须由调用方创建并持有(例如窗体上拖一个PrintDialog+PrintPreviewDialog),模块只负责注入绘制逻辑,不管理生命周期。这是解耦关键——避免模块持有 UI 控件引用导致内存泄漏。

2.3 分页渲染主流程:从“第一页该画啥”开始推演

核心方法RenderPage()不是简单循环画格子,而是分四步推进:

  1. 页内可用区域计算:e.MarginBounds是物理纸张扣除页边距后的矩形,但需进一步减去页眉/页脚预留高度;
  2. 当前页起始行索引确定:根据上一页已绘制行数currentPageStartRow和每页最大行数maxRowsPerPage推算;
  3. 行高累加与跨页截断判断:逐行累加GetRowHeight(rowIndex)(考虑RowTemplate.Height、AutoSizeRowsMode、字体实际测量高度),一旦超出页高,立即终止本页绘制,记录中断点;
  4. 逐单元格绘制:对当前页所有行、所有列,调用DrawCell(graphics, cellRect, rowIndex, colIndex),内部处理背景色、边框、文字、对齐、换行等。
private void RenderPage(Graphics g, RectangleF pageBounds, PageSettings pageSettings) { var headerHeight = CalculateHeaderHeight(g); // 表头高度(含字体+内边距) var footerHeight = CalculateFooterHeight(g); // 页脚高度(含页码+时间) var usableHeight = pageBounds.Height - headerHeight - footerHeight; int startRow = _currentPageStartRow; int drawnRows = 0; float currentY = pageBounds.Top + headerHeight; while (drawnRows < _maxRowsPerPage && startRow < _grid.Rows.Count && currentY + GetRowHeight(startRow) <= pageBounds.Bottom - footerHeight) { float rowHeight = GetRowHeight(startRow); float rowTop = currentY; // 绘制该行所有列 for (int colIndex = 0; colIndex < _grid.Columns.Count; colIndex++) { var cellRect = new RectangleF( pageBounds.Left + GetColumnOffset(colIndex), rowTop, GetColumnWidth(colIndex), rowHeight ); DrawCell(g, cellRect, startRow, colIndex); } currentY += rowHeight; drawnRows++; startRow++; } _currentPageStartRow = startRow; // 记录下一页起始行 e.HasMorePages = startRow < _grid.Rows.Count; // 是否还有下一页 }

GetColumnOffset()和GetColumnWidth()必须基于DataGridView实际列宽(Columns[i].Width),而非设计时固定值——因为用户可能拖拽调整过列宽,模块必须尊重运行时状态。


3. 样式还原:如何让打印出来的字,和屏幕上看到的一模一样?

3.1 字体与颜色:从 DataGridViewCellStyle 到 GDI+ 实例的精准映射

DataGridView 的样式存储在DataGridViewCellStyle中,但Graphics.DrawString()需要Font和Brush。直接new Font(style.Font)会出问题:

  • style.Font可能为null(继承自父级),需向上查找直到获得有效字体;
  • style.ForeColor/style.BackColor可能是Color.Empty,需 fallback 到SystemColors;
  • 更致命的是:打印时 DPI 通常为 600–1200,而屏幕 DPI 是 96–144,相同字号在打印纸上会小得离谱。

正确做法是:按打印机 DPI 缩放字号。假设屏幕 DPI 为 96,打印机 DPI 为 600,则缩放系数为600 / 96 ≈ 6.25:

private Font GetEffectiveFont(int rowIndex, int colIndex) { DataGridViewCellStyle style = GetCellStyle(rowIndex, colIndex); if (style.Font == null) return SystemFonts.DefaultFont; // 获取打印机水平DPI(更准确应取g.DpiX,但PrintPageEventArgs中g未暴露DpiX,故用PageSettings) float dpiScale = _printDoc.DefaultPageSettings.PrinterResolution.X / 96f; float scaledSize = style.Font.Size * dpiScale; return new Font(style.Font.FontFamily, scaledSize, style.Font.Style); } private Brush GetBackgroundBrush(int rowIndex, int colIndex) { DataGridViewCellStyle style = GetCellStyle(rowIndex, colIndex); Color backColor = style.BackColor == Color.Empty ? (rowIndex % 2 == 0 ? SystemColors.Window : SystemColors.ControlLight) : style.BackColor; return new SolidBrush(backColor); }

注意:_printDoc.DefaultPageSettings.PrinterResolution.X是获取打印机真实 DPI 的最可靠方式(比g.DpiX更稳定),它来自系统打印机驱动,无需额外查询。

3.2 边框与对齐:用 Pen 宽度模拟像素级精度

DataGridView 的GridColor和BorderStyle在打印时不能简单套用ControlPaint.DrawBorder()——它生成的是屏幕适配的粗边框。打印需精细控制:

  • 边框宽度必须按 DPI 缩放:new Pen(gridColor, 1f * dpiScale);
  • 四边分别绘制(DrawLine),而非DrawRectangle,避免角部重叠;
  • 文字对齐需转换DataGridViewContentAlignment→StringFormat:
private StringFormat GetStringFormat(DataGridViewContentAlignment align) { var format = new StringFormat(); switch (align) { case DataGridViewContentAlignment.TopLeft: format.Alignment = StringAlignment.Near; format.LineAlignment = StringAlignment.Near; break; case DataGridViewContentAlignment.MiddleCenter: format.Alignment = StringAlignment.Center; format.LineAlignment = StringAlignment.Center; break; case DataGridViewContentAlignment.BottomRight: format.Alignment = StringAlignment.Far; format.LineAlignment = StringAlignment.Far; break; // 其他情况类似... } format.Trimming = StringTrimming.EllipsisCharacter; format.FormatFlags = StringFormatFlags.NoClip; // 允许文字溢出单元格(如长文本) return format; }

NoClip是关键:否则长文本会被强行截断,而实际业务中常需显示完整字段(如地址、备注)。

3.3 合并单元格:跨行跨列的“状态机”式渲染

DataGridView 支持DataGridViewCell.OwningColumnSpan和OwningRowSpan,但打印时不能直接用FillRectangle覆盖——必须识别“谁是主单元格,谁是附属单元格”。模块采用主单元格优先绘制,附属单元格跳过策略:

private bool IsMasterCell(int rowIndex, int colIndex) { // 查找左上角主单元格 for (int r = rowIndex; r >= 0; r--) { for (int c = colIndex; c >= 0; c--) { var cell = _grid[r, c]; if (cell.OwningRowSpan > 1 || cell.OwningColumnSpan > 1) { // 主单元格:其左上角必为 (r, c),且满足 r + RowSpan - 1 >= rowIndex && c + ColSpan - 1 >= colIndex if (r + cell.OwningRowSpan - 1 >= rowIndex && c + cell.OwningColumnSpan - 1 >= colIndex) return r == rowIndex && c == colIndex; } } } return true; // 无合并时,每个都是主单元格 }

绘制时先调用IsMasterCell(),仅为主单元格执行DrawCell(),附属单元格直接 return。这样既保证合并区只画一次,又避免因行列偏移导致的错位。


4. 避坑指南:那些让打印模块上线前集体翻车的 4 个硬伤

4.1 现象:打印预览正常,真机输出时文字模糊、线条发虚

原因:未启用Graphics.SmoothingMode = SmoothingMode.AntiAlias,且未设置TextRenderingHint = TextRenderingHint.ClearTypeGridFit。打印机高 DPI 下,GDI+ 默认使用低质量文本渲染,ClearType 在打印场景反而劣化效果。
解决:在OnPrintPage开头强制设置:

e.Graphics.SmoothingMode = SmoothingMode.HighQuality; e.Graphics.TextRenderingHint = TextRenderingHint.SingleBitPerPixelGridFit; // 关键!打印用单色抗锯齿 e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;

注意:SingleBitPerPixelGridFit是打印文本清晰度的“后悔药”,AntiAlias会导致细线变粗、小字号糊成一片。

4.2 现象:中文字符显示为方框或乱码

原因:PrintDocument默认使用System.Drawing.Font,其底层 GDI 不自动回退中文字体。当指定字体(如 Arial)不含中文时,不会自动切换到 SimSun,而是画方框。
解决:创建字体时显式指定备选字体链:

private Font CreateChineseFont(float size) { // 尝试微软雅黑,失败则回退宋体,再失败用默认 try { return new Font("Microsoft YaHei", size, FontStyle.Regular); } catch { try { return new Font("SimSun", size, FontStyle.Regular); } catch { return SystemFonts.DefaultFont; } } }

并在GetEffectiveFont()中调用此方法,而非直接new Font(...)。

4.3 现象:启用AutoSizeColumnsMode.AllCells后,打印列宽严重失真

原因:AutoSizeMode是运行时动态计算的,但打印时DataGridView未处于可见状态,GetPreferredSize()返回错误尺寸(常为 0 或极小值)。
解决:打印前临时将DataGridView设为可见(Visible = true),并调用Update()强制刷新布局,再读取Columns[i].Width。完成后立即设回Visible = false(不影响 UI):

// 打印前 _grid.Visible = true; _grid.Update(); // ...执行打印... _grid.Visible = false;

血泪经验:此操作必须在PrintDialog.ShowDialog()之后、PrintDocument.Print()之前完成,否则Update()无效。

4.4 现象:页眉页脚内容在不同打印机上位置漂移

原因:e.MarginBounds.Top是页边距起点,但部分打印机驱动会额外添加不可见的“机械边距”,导致页眉实际位置下移。
解决:引入页眉校准偏移量参数,允许用户在首次打印后微调:

public float HeaderOffset { get; set; } = 0f; // 单位:毫米,正数下移,负数上移 // 在 RenderPage 中: float headerTop = pageBounds.Top + HeaderOffset * 2.83464567f; // mm → pixels (96dpi)

2.83464567 是 96 DPI 下 1mm 对应的像素数(1 inch = 25.4mm, 96px/inch → 96/25.4 ≈ 3.7795,但此处用 2.8346 是为匹配 A4 纸常用 72dpi 基准,实测更稳)。


5. 进阶实战:带 Logo、页码、动态页眉的工业级打印模板

5.1 页眉页脚的模块化注入机制

真正的“万能”,在于支持外部注入而非硬编码。模块提供IPageHeaderFooter接口:

public interface IPageHeaderFooter { SizeF MeasureHeader(Graphics g, RectangleF bounds); // 返回所需高度 void DrawHeader(Graphics g, RectangleF bounds, int currentPage, int totalPages); SizeF MeasureFooter(Graphics g, RectangleF bounds); void DrawFooter(Graphics g, RectangleF bounds, int currentPage, int totalPages); }

调用方可实现自定义类,例如CompanyReportHeader:

public class CompanyReportHeader : IPageHeaderFooter { private readonly Image _logo; private readonly string _title; public CompanyReportHeader(Image logo, string title) { _logo = logo; _title = title; } public SizeF MeasureHeader(Graphics g, RectangleF bounds) { // 计算 Logo + 标题所需高度 var logoHeight = Math.Min(_logo.Height * 0.5f, bounds.Height * 0.3f); // 缩放至页高30% return new SizeF(bounds.Width, logoHeight + 20f); // 标题留20px } public void DrawHeader(Graphics g, RectangleF bounds, int currentPage, int totalPages) { // 绘制 Logo(左上) g.DrawImage(_logo, bounds.Left + 10, bounds.Top + 5, _logo.Width * 0.5f, _logo.Height * 0.5f); // 绘制标题(居中) using var titleFont = new Font("Microsoft YaHei", 14f, FontStyle.Bold); var titleSize = g.MeasureString(_title, titleFont); g.DrawString(_title, titleFont, Brushes.Black, bounds.Left + (bounds.Width - titleSize.Width) / 2, bounds.Top + 5 + _logo.Height * 0.5f + 5); // 绘制打印时间(右上) var timeStr = $"打印时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}"; using var timeFont = new Font("Arial", 9f); var timeSize = g.MeasureString(timeStr, timeFont); g.DrawString(timeStr, timeFont, Brushes.Gray, bounds.Right - timeSize.Width - 10, bounds.Top + 5 + _logo.Height * 0.5f + 5); } // Footer 同理,略 }

使用时只需:

var printer = new DataGridViewPrinter(dataGridView1, printDocument1); printer.HeaderFooter = new CompanyReportHeader(Properties.Resources.Logo, "销售日报表");

5.2 动态页码与分页统计:不只是 “第 X 页”

用户常需 “第 3 页(共 12 页)” 或 “第 3 页(总计 287 条)”。模块在RenderPage结束后,通过PrintDocument.QueryPageSettings事件预获取总页数(需两次打印:第一次仅计算页数,第二次才绘制):

private int _totalPages = 0; private bool _isFirstPass = true; private void OnQueryPageSettings(object sender, QueryPageSettingsEventArgs e) { if (_isFirstPass) { // 第一次:只计算总页数,不绘制 _totalPages = CalculateTotalPages(); _isFirstPass = false; e.Cancel = true; // 取消本次打印 } } private int CalculateTotalPages() { int pageCount = 0; int row = 0; while (row < _grid.Rows.Count) { int rowsInPage = 0; float y = 0; while (row < _grid.Rows.Count && y + GetRowHeight(row) <= _usablePageHeight) { y += GetRowHeight(row); row++; rowsInPage++; } pageCount++; } return pageCount; }

然后在DrawFooter中传入_totalPages,即可输出完整页码信息。

5.3 打印性能优化:避免每次重绘都重新测量

对千行级表格,GetRowHeight()被调用数千次,其中Graphics.MeasureString()是性能黑洞。解决方案:缓存行高。

private readonly Dictionary<int, float> _rowHeightCache = new(); private float GetRowHeight(int rowIndex) { if (_rowHeightCache.TryGetValue(rowIndex, out float h)) return h; // 实际测量逻辑(含字体、行高模式判断) h = MeasureRowHeight(rowIndex); _rowHeightCache[rowIndex] = h; return h; }

首次打印后,缓存生效;若用户修改了某行Height或字体,调用InvalidateCache()清空即可。

我习惯在模块初始化后加一行日志:Debug.WriteLine($"[Print] Cached {printer.RowHeightCache.Count} row heights");—— 这不是为了监控,而是当打印变慢时,一眼看出缓存是否生效。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询