UltraGrid打印深度实践:WinForms表格精准排版与工业级报表实现

发布时间:2026/10/21 18:48:00
UltraGrid打印深度实践:WinForms表格精准排版与工业级报表实现
1. 项目概述为什么一个表格控件的打印功能值得单独深挖在某高校实验室开发一套设备运行状态监控系统时我接手了一个“看起来很简单”的需求把UltraGrid里展示的实时数据导出成可直接交付给运维人员的纸质报表。当时心里还嘀咕——不就是点个“打印”按钮吗结果三天后我在会议室被产品负责人当着客户面问“为什么打印出来的表头错位、字体发虚、跨页断行乱七八糟连页码都对不上”那一刻我才意识到UltraGrid的PrintDocument模式不是调用一个方法就完事的黑盒而是一套需要手动缝合渲染逻辑、分页策略、样式映射和物理纸张约束的微型排版引擎。这根本不是“控件自带功能”而是用C#在Windows GDI底层和Infragistics封装层之间反复调试的硬功夫。UltraGrid作为老牌WinForms第三方控件它的打印能力长期被低估——它不提供一键PDF导出也不默认适配A4纸边距更不会自动处理合并单元格跨页断裂。但正因如此它留出了足够精细的控制入口从PrintSettings的PageSetup到PrintPreviewDialog的Document事件从UltraGrid.Printing.PrintRow的Draw重写到UltraGridBand.Printing.PrintColumn的Width动态计算每一步都是可干预的。我后来在三个不同行业的项目中复用这套方案某制造企业的工单追溯系统要求带条形码签名栏、某医疗设备管理平台需双语表头合规水印、某电力巡检APP的离线报表模块必须支持无打印机环境下的预览缩放。它们共同验证了一件事UltraGrid打印的本质是把网格数据结构翻译成符合ISO 216纸张标准的视觉流式布局。如果你正在用WinForms做工业软件、医疗系统或政府内网应用又卡在打印效果不达标上这篇内容就是为你写的——它不讲API列表只讲怎么让每一毫米的纸张都被精准利用。2. 核心设计思路绕开“自动打印”的陷阱构建三层可控流水线UltraGrid官方文档里那个ultraGrid1.Print()方法就像给你一把没校准的游标卡尺——能测但误差大到无法接受。我试过直接调用结果发现表头固定行在第二页顶部消失、合计行被截断在页脚、中文宋体在激光打印机上出现锯齿、甚至同一台机器换不同驱动版本输出效果都不一致。问题根源在于Infragistics的默认打印路径走的是GDI的Graphics.DrawString直绘它把网格当成静态位图渲染完全忽略了文本的基线对齐、字间距微调和分页时的行高累积误差。要破局必须放弃“自动”思维用三层流水线重构整个流程2.1 数据层从GridView到结构化打印数据源的剥离UltraGrid的DataSource可能是DataTable、BindingList 甚至自定义对象集合但打印时真正需要的不是原始数据而是已过滤、已排序、已格式化的行序列。比如某列显示“2023-10-05 14:30:22”打印时却要转成“10/05/2023 14:30”。如果在PrintRow.Draw事件里做格式化会导致重复计算每页重绘都执行且无法统一控制精度。我的做法是提前构建PrintDataRow类public class PrintDataRow { public string[] CellValues { get; set; } // 预格式化后的字符串数组 public bool IsSummaryRow { get; set; } // 是否为合计行影响分页逻辑 public double RowHeightMM { get; set; } // 毫米单位行高避免像素与DPI换算错误 }构建过程在打印触发前完成private ListPrintDataRow BuildPrintData() { var printData new ListPrintDataRow(); foreach (UltraGridRow row in ultraGrid1.Rows) { if (!row.IsGroupByRow !row.IsFilterRow) // 过滤掉非数据行 { var values new string[ultraGrid1.DisplayLayout.Bands[0].Columns.Count]; for (int i 0; i values.Length; i) { var col ultraGrid1.DisplayLayout.Bands[0].Columns[i]; // 关键用Column.FormatMask或自定义ToString()获取最终显示值 values[i] GetFormattedCellValue(row, col); } printData.Add(new PrintDataRow { CellValues values, IsSummaryRow row.Cells[Total].Value ! null, RowHeightMM CalculateRowHeightMM(row) // 根据字体大小行间距计算 }); } } return printData; }提示GetFormattedCellValue必须严格复用UltraGrid的显示逻辑否则会出现界面上看到的数字是“12,345.67”打印出来却是“12345.67”的尴尬。我专门写了单元测试对比两者输出确保小数位、千分位、空值占位符如“N/A”完全一致。2.2 布局层用物理尺寸而非像素定义一切WinForms默认用像素Pixel做单位但打印机用的是点Point和毫米Millimeter。1英寸72点25.4毫米而屏幕DPI通常96打印机DPI动辄600。如果直接用Graphics.MeasureString测像素宽再除以Graphics.DpiX换算会因GDI的亚像素渲染导致累计误差——100行表格可能整体偏移2mm以上。我的解决方案是全程使用毫米制// 获取打印机真实DPI非屏幕DPI private static float GetPrinterDpiX(PrintDocument doc) { using (var g doc.PrinterSettings.CreateMeasurementGraphics()) { return g.DpiX; } } // 将毫米转换为打印机坐标 private static int MMToPrinterUnits(float mm, float dpi) { return (int)Math.Round(mm * dpi / 25.4f); }所有关键尺寸都基于此换算表头高度8mm →MMToPrinterUnits(8, printerDpi)列宽根据列名长度数据最长值动态计算最小宽度设为25mm避免文字挤压页边距上25mm/下20mm/左15mm/右15mm符合国内公文规范这样做的好处是无论用户用HP LaserJet还是Epson针式打印机只要驱动正确输出位置误差控制在±0.1mm内。我在某电力项目验收时客户用游标卡尺实测报表边距结果与代码设定值完全吻合。2.3 渲染层放弃DrawString改用TextRenderer.DrawText精确控制Graphics.DrawString在GDI中属于“高质量文本渲染”但它会启用ClearType子像素抗锯齿在激光打印机上反而导致文字发虚。而TextRenderer.DrawText走的是GDI的TextOut路径输出锐利度更高且支持TextFormatFlags.NoPadding等精细控制。UltraGrid打印事件中的e.Graphics对象我强制用TextRenderer重绘private void ultraGrid1_PrintRow(object sender, PrintRowEventArgs e) { var g e.Graphics; var bounds e.Bounds; // 用TextRenderer替代DrawString TextRenderer.DrawText(g, e.Row.Cells[0].Text, new Font(SimSun, 9f, FontStyle.Regular), new Rectangle(bounds.Left, bounds.Top, bounds.Width, bounds.Height), Color.Black, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); }注意TextRenderer不支持StringFormat的复杂对齐所以必须手动计算文本在矩形内的垂直居中偏移量。公式是y bounds.Top (bounds.Height - textHeight) / 2其中textHeight用TextRenderer.MeasureText预先测得。这个细节决定了表头文字是否“悬在行中间”而不是贴着顶部。这三层设计彻底绕开了UltraGrid默认打印的不可控性。数据层保证内容准确布局层保证位置精准渲染层保证输出锐利——三者缺一不可。3. 实操核心环节从零搭建可复用的打印引擎现在把上述思路落地为可直接复制的代码模块。整个打印引擎由四个核心类组成UltraGridPrinter主控制器、PrintPageLayout页面布局计算器、PrintCellRenderer单元格绘制器、PrintPreviewDialogEx增强预览对话框。下面逐个拆解关键实现。3.1 UltraGridPrinter主控类与生命周期管理这个类封装了从触发打印到生成文档的全过程重点解决两个痛点如何让打印逻辑与UI线程解耦如何支持后台预生成PDFpublic class UltraGridPrinter { private readonly UltraGrid _grid; private readonly PrintDocument _document; private ListPrintDataRow _printData; private PrintPageLayout _pageLayout; public UltraGridPrinter(UltraGrid grid) { _grid grid; _document new PrintDocument(); _document.BeginPrint OnBeginPrint; _document.PrintPage OnPrintPage; _document.EndPrint OnEndPrint; } public void ShowPrintPreview() { // 关键预生成数据避免预览时卡UI _printData BuildPrintData(); _pageLayout new PrintPageLayout(_printData, _grid); var preview new PrintPreviewDialogEx(_document); preview.ShowDialog(); } private void OnBeginPrint(object sender, PrintEventArgs e) { // 初始化页面计数、总页数 _pageLayout.ResetPageCounter(); } private void OnPrintPage(object sender, PrintPageEventArgs e) { var pageData _pageLayout.GetPageData(e.PageSettings); var renderer new PrintCellRenderer(e.Graphics, e.PageSettings); // 绘制页眉含公司Logo、报表标题、日期 renderer.DrawHeader(pageData.PageNumber, _pageLayout.TotalPages); // 绘制表格主体 renderer.DrawGridBody(pageData.Rows, _pageLayout.ColumnWidths); // 绘制页脚含页码、打印时间、机密等级水印 renderer.DrawFooter(pageData.PageNumber); e.HasMorePages _pageLayout.HasMorePages; } }实操心得OnBeginPrint里不要做耗时操作我曾把BuildPrintData()放在这里结果预览对话框打开要等5秒。正确做法是ShowPrintPreview时就预生成OnBeginPrint只做轻量初始化。另外_pageLayout.GetPageData()返回的pageData.Rows是当前页所有行的索引范围不是数据副本极大节省内存。3.2 PrintPageLayout智能分页算法的核心分页是UltraGrid打印最头疼的部分。默认行为会在行中间切断导致“上半行在页尾下半行在页首”。我的算法叫“安全行高预留法”先计算可用打印区域高度再按行高累加当剩余高度单行最小高度时强制翻页并把当前行移到下一页开头。public class PrintPageLayout { private readonly ListPrintDataRow _allRows; private readonly UltraGrid _grid; private int _currentPageIndex 0; private readonly Listint _pageBreakPoints new Listint(); public PrintPageLayout(ListPrintDataRow allRows, UltraGrid grid) { _allRows allRows; _grid grid; CalculatePageBreaks(); } private void CalculatePageBreaks() { _pageBreakPoints.Clear(); _pageBreakPoints.Add(0); // 第一页从第0行开始 var pageSettings new PageSettings(); var usableHeight GetUsablePrintHeight(pageSettings); // 减去页眉页脚后的净高 int rowIndex 0; while (rowIndex _allRows.Count) { double accumulatedHeight 0; int nextPageStart rowIndex; while (rowIndex _allRows.Count accumulatedHeight usableHeight) { accumulatedHeight _allRows[rowIndex].RowHeightMM; // 关键遇到合计行必须保证其完整显示不跨页 if (_allRows[rowIndex].IsSummaryRow) { // 如果加上这行就超了且不是第一页则把合计行推到下一页 if (accumulatedHeight usableHeight _pageBreakPoints.Count 1) { nextPageStart rowIndex; break; } } rowIndex; } if (nextPageStart _pageBreakPoints.Last()) _pageBreakPoints.Add(nextPageStart); } } public PageData GetPageData(PageSettings settings) { var currentPage _currentPageIndex; if (currentPage _pageBreakPoints.Count - 1) return new PageData { Rows new ListPrintDataRow(), PageNumber currentPage 1 }; var startRow _pageBreakPoints[currentPage]; var endRow _pageBreakPoints[currentPage 1]; var rows _allRows.Skip(startRow).Take(endRow - startRow).ToList(); _currentPageIndex; return new PageData { Rows rows, PageNumber currentPage 1, TotalPages _pageBreakPoints.Count - 1 }; } }注意事项GetUsablePrintHeight必须动态计算。我见过太多人写死“800像素”结果在高DPI显示器上预览正常实际打印时页脚被切掉。正确方式是settings.PaperSize.Height - settings.Margins.Top - settings.Margins.Bottom单位是1/100英寸再换算成毫米。3.3 PrintCellRenderer像素级控制的绘制器这是决定打印质量的最后防线。TextRenderer.DrawText虽好但默认不处理换行。当单元格内容超宽时必须手动折行并计算行数。我的方案是对每列预设最大字符数根据列宽反推超过则用Environment.NewLine插入换行符再用TextRenderer.MeasureText测总高度。public class PrintCellRenderer { private readonly Graphics _g; private readonly PageSettings _settings; private readonly Font _headerFont new Font(SimSun, 10f, FontStyle.Bold); private readonly Font _bodyFont new Font(SimSun, 9f, FontStyle.Regular); public PrintCellRenderer(Graphics g, PageSettings settings) { _g g; _settings settings; } public void DrawGridBody(ListPrintDataRow rows, Listint columnWidths) { var startY GetHeaderBottomY(); // 页眉底部Y坐标 var currentY startY; foreach (var row in rows) { var rowHeight MMToPrinterUnits(row.RowHeightMM, _settings.PrinterResolution.X); var rowRect new Rectangle(0, (int)currentY, _settings.PaperSize.Width, rowHeight); for (int colIndex 0; colIndex row.CellValues.Length; colIndex) { var cellValue row.CellValues[colIndex]; var colWidth columnWidths[colIndex]; var cellRect new Rectangle( GetColumnX(colIndex, columnWidths), (int)currentY, colWidth, rowHeight); // 关键处理长文本自动换行 var wrappedText WrapText(cellValue, colWidth, _bodyFont); TextRenderer.DrawText(_g, wrappedText, _bodyFont, cellRect, Color.Black, TextFormatFlags.Left | TextFormatFlags.WordBreak); } currentY rowHeight; } } private string WrapText(string text, int maxWidth, Font font) { if (string.IsNullOrEmpty(text)) return text; // 用TextRenderer.MeasureText测单行能容纳多少字符 var singleLineWidth TextRenderer.MeasureText(_g, text, font).Width; if (singleLineWidth maxWidth) return text; // 简单按字符数折行实际项目中用更智能的单词分割 var charsPerLine (int)(maxWidth * text.Length / (double)singleLineWidth); var lines new Liststring(); for (int i 0; i text.Length; i charsPerLine) { lines.Add(text.Substring(i, Math.Min(charsPerLine, text.Length - i))); } return string.Join(Environment.NewLine, lines); } }踩过的坑TextRenderer.MeasureText在不同.NET Framework版本下行为不一致。.NET 4.7.2之前它不支持TextFormatFlags.WordBreak必须手动分词。我在某银行项目中因此返工最终统一升级到.NET 4.8并加了版本检测。3.4 PrintPreviewDialogEx超越原生的预览体验原生PrintPreviewDialog只能看不能导出PDF、不能缩放、不能选中文字。我继承它并注入新功能public class PrintPreviewDialogEx : PrintPreviewDialog { private ToolStripButton _exportPdfButton; private ToolStripButton _zoomInButton; private ToolStripButton _zoomOutButton; protected override void OnLoad(EventArgs e) { base.OnLoad(e); AddCustomToolbar(); } private void AddCustomToolbar() { var toolBar (ToolStrip)this.Controls.Find(toolStrip1, true)[0]; _exportPdfButton new ToolStripButton(导出PDF); _exportPdfButton.Click (s, e) ExportToPdf(); toolBar.Items.Add(_exportPdfButton); _zoomInButton new ToolStripButton(); _zoomInButton.Click (s, e) Zoom(1.2); toolBar.Items.Add(_zoomInButton); } private void ExportToPdf() { // 使用iTextSharp生成PDF需NuGet引用 var pdfDoc new Document(PageSize.A4, 15, 15, 25, 20); var writer PdfWriter.GetInstance(pdfDoc, new FileStream(report.pdf, FileMode.Create)); pdfDoc.Open(); // 将PrintDocument内容渲染到PDF var pdfGraphics PdfContentByte.GetDirectContent(writer.DirectContent); // ... 此处省略具体渲染逻辑核心是复用PrintCellRenderer的绘制逻辑 pdfDoc.Close(); } }这个增强版预览对话框让客户能当场导出PDF发邮件再也不用抱怨“为什么不能直接发电子版”。4. 常见问题与排查技巧实录那些文档里绝不会写的真相在六个不同项目中部署这套方案我整理出一份血泪教训清单。这些问题90%的开发者都会遇到但网上几乎找不到答案。4.1 问题速查表症状、原因、解决方案症状根本原因解决方案实测耗时打印预览正常实际打印时文字模糊GDI ClearType在打印机驱动中被强制关闭DrawString输出失真改用TextRenderer.DrawText并设置TextFormatFlags.NoPadding2小时表头在第二页顶部消失UltraGrid默认将固定行Frozen Rows视为“始终显示”但打印时未重绘在PrintRow事件中当e.RowIndex 0且e.PageNumber 1时手动重绘表头行4小时中文宋体在激光打印机上出现“断笔”如“横”字少一横字体嵌入问题打印机ROM中无SimSun字体回退到默认字体在PrintDocument的QueryPageSettings事件中强制指定e.PageSettings.PrinterResolution.X 600并用Graphics.TextRenderingHint TextRenderingHint.SingleBitPerPixelGridFit1天同一报表在不同电脑上打印位置偏移2mm屏幕DPI与打印机DPI混用Graphics.DpiX返回的是屏幕DPI而非打印机DPI放弃Graphics.DpiX改用PrinterSettings.CreateMeasurementGraphics().DpiX获取真实打印机DPI3小时导出PDF时表格线断裂、虚线变实线iTextSharp的PdfPTable边框渲染与GDI不一致不用iTextSharp画表格改用Image.GetInstance将GDI渲染的位图嵌入PDF6小时4.2 独家避坑技巧来自产线的真实经验技巧1用“打印沙盒”快速验证布局别每次改代码都跑完整流程。我写了个PrintSandbox工具窗体里面放一个Panel模拟A4纸210×297mm用CreateGraphics()在上面绘制实时调整MMToPrinterUnits参数。改一行代码3秒内看到效果比调试打印快10倍。技巧2字体嵌入的终极方案客户现场总有奇葩打印机比如老式OKI针式机。与其纠结字体不如把表头文字转成Pathprivate GraphicsPath TextToPath(string text, Font font, RectangleF layoutRect) { var path new GraphicsPath(); var format new StringFormat { Alignment StringAlignment.Center, LineAlignment StringAlignment.Center }; path.AddString(text, font.FontFamily, (int)font.Style, font.Size, layoutRect, format); return path; } // 然后用Graphics.FillPath填充这样字体再奇怪也能保证形状准确技巧3处理UltraGrid的“幽灵行高”UltraGrid的RowHeight属性在打印时经常失效因为它是像素值。我的补丁是在PrintRow事件中用e.Graphics.MeasureString(测试, font).Height实测行高再乘以1.2作为安全系数动态覆盖e.Bounds.Height。这招解决了80%的跨页错位问题。技巧4页码同步的隐藏陷阱PrintDocument的PrintPage事件中e.PageSettings的Margins可能被用户在打印对话框里修改导致页眉页脚Y坐标计算错误。必须在QueryPageSettings事件中缓存用户选择的边距private Margins _userMargins; private void OnQueryPageSettings(object sender, QueryPageSettingsEventArgs e) { _userMargins e.PageSettings.Margins; } // 然后在DrawHeader中用_userMargins.Top计算起始Y这些技巧没有一条写在Infragistics官方文档里全是我在凌晨三点对着打印机吐出的废纸堆里扒出来的。5. 扩展可能性从打印到企业级报表的跃迁这套UltraGrid打印引擎本质是一个轻量级报表框架的雏形。我在某医疗器械公司的项目中把它扩展成了支持以下特性的生产系统动态列配置用户在界面上拖拽列顺序、隐藏列、设置列宽配置保存到XML打印时自动加载条件格式打印某列值100时整行背景标红用Brushes.Red填充e.Bounds多表头合并用PrintCellRenderer的DrawRectangle手动画合并边框支持跨列合并条形码集成在最后一列插入Code128条形码用ZXing.Net生成Bitmap再e.Graphics.DrawImage绘制电子签章在页脚预留签名区调用Windows Ink API捕获手写签名转为PNG嵌入。最关键的跃迁是与Crystal Reports的协同当客户提出“需要更复杂报表”时我没有推倒重来而是把UltraGrid打印引擎作为Crystal Reports的“数据源适配器”。Crystal Reports负责复杂分组和图表UltraGrid负责实时数据表格部分两者通过共享PrintDataRow列表无缝衔接。最后分享一个小技巧UltraGrid的PrintSettings有个隐藏属性PrintSettings.PrintMode PrintMode.Native设为PrintMode.Extended才能启用全部自定义事件。这个开关在设计器里找不到必须代码设置。我第一次知道是在Infragistics技术支持的电话里对方说“哦这个啊我们内部叫‘高级打印模式’文档里没写是因为怕用户搞乱...”这套方案的价值不在于它多炫酷而在于它让一个看似过时的WinForms控件在2024年依然能扛起企业级打印的重担。当你面对客户指着报表说“这里不对”时你不再需要祈祷控件自动修复而是能打开代码精准定位到第327行改一个参数重新打印——那种掌控感才是工程师真正的底气。