ARTICLE DETAIL

资讯详情

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

WinForms DataGridView打印模块:高保真样式还原与智能分页

WinForms DataGridView打印模块:高保真样式还原与智能分页 简介这是一套面向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 ListPrintPageInfo _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必须由调用方创建并持有例如窗体上拖一个PrintDialogPrintPreviewDialog模块只负责注入绘制逻辑不管理生命周期。这是解耦关键——避免模块持有 UI 控件引用导致内存泄漏。2.3 分页渲染主流程从“第一页该画啥”开始推演核心方法RenderPage()不是简单循环画格子而是分四步推进页内可用区域计算e.MarginBounds是物理纸张扣除页边距后的矩形但需进一步减去页眉/页脚预留高度当前页起始行索引确定根据上一页已绘制行数currentPageStartRow和每页最大行数maxRowsPerPage推算行高累加与跨页截断判断逐行累加GetRowHeight(rowIndex)考虑RowTemplate.Height、AutoSizeRowsMode、字体实际测量高度一旦超出页高立即终止本页绘制记录中断点逐单元格绘制对当前页所有行、所有列调用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.25private 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→StringFormatprivate 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); }调用方可实现自定义类例如CompanyReportHeaderpublic 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 Dictionaryint, 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);—— 这不是为了监控而是当打印变慢时一眼看出缓存是否生效。希望帮到你。本文还有配套的精品资源点击获取
返回列表