尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC打印从原理到实战:DC映射、分页与位图输出全解析

MFC打印从原理到实战:DC映射、分页与位图输出全解析 简介这是一套完整的MFC打印功能实现示例工程面向需要在Visual C环境下为文档视图架构增加打印输出的Windows开发者。资源围绕CView派生类、打印机设备上下文DC创建、StartDoc/StartPage/EndDoc等核心流程通过可直接运行的MFC_PrintAll项目演示了如何将屏幕绘制OnDraw逻辑适配到打印机DC并给出打印预览与分页处理思路。压缩包共50个文件约22.82MB包括vcxproj/sln工程文件、h/cpp源码、rc资源描述、exe可执行程序以及pdb/ilk等调试文件结构清晰可用Visual Studio直接打开编译运行。目前已有344人学习下载适合Windows桌面应用开发者结合代码理解MFC打印机制既能用于课堂实验也能作为实际项目中接入打印功能、扩展页面设置与错误处理的参考骨架。 一直以来MFC框架里的打印功能都被当成“能用就行”的模块但真到项目交付、客户现场演示、或者需要输出报表底单的时候各种问题就藏不住了预览明明是好的打印机出来却错位中文变成方块多页文档只打了第一页图片打印出来全是锯齿。这些问题我早期都踩过后来把MFC打印的整个调用链、坐标映射、分页机制彻底理清之后才算是真正“搞定”了打印这件事。这篇内容我就不讲那些文档里已经有了的Hello World式教程了直接从MFC打印的底层运作逻辑讲起把单页、多页、预览、位图输出这几个最常见的场景逐一拆开再把我在实际项目里踩过的坑和处理方案一并说出来。不管你是刚接手MFC维护任务的新手还是已经在Windows桌面开发里摸爬滚打了几年的老手这篇都应该能让你少走不少弯路。1. 先把MFC打印的底子摸清CDC与映射模式很多人写MFC打印功能第一步就去看CView::OnPrint怎么重写、CPrintDialog怎么弹出来但其实真正决定打印质量的是更底层的一对组合打印机DCDevice Context和映射模式Map Mode。不理解这两样东西后面所有的“调格式”都是在瞎试。1.1 打印输出的本质一切都是DC绘图MFC里屏幕绘制和打印绘制本质上走的是同一套GDI接口——都是往DC上画东西。你在屏幕上看到的OnDraw(CDC* pDC)在打印时MFC框架会“骗”你把打印机的DC也包装好传进来所以理论上你可以用同一份绘图代码同时服务屏幕和打印机。但这里有个致命的差别显示器的DC是像素逻辑打印机的DC是物理设备逻辑。显示器通常按像素Pixel工作96 DPI每英寸点数或更高。打印机按物理分辨率工作常见300 DPI、600 DPI甚至1200 DPI。如果你直接用像素坐标去打印在屏幕上画一条100像素的线到打印机上可能就变成不到1厘米的细线反过来你要是按屏幕的坐标体系去排一个表格打到纸上就会小得没法看。1.2 映射模式选择别再死磕MM_TEXTMFC默认的映射模式是MM_TEXT也就是一个逻辑单位等于一个像素Y轴向下为正。这种模式在屏幕上用着顺手但在打印时是“噩梦模式”因为同样的逻辑单位在不同DPI的设备上渲染出来的物理尺寸完全不一样。打印功能要稳定第一步就是把映射模式改掉。我推荐按需求二选一映射模式逻辑单位适用场景说明MM_TWIPS1/1440 英寸文本报表、票据经典排版单位1磅 20 TWIPMM_HIMETRIC1/100 毫米工程图、CAD类输出高精度坐标原点在左上角MM_ISOTROPIC自定义比例预览强制等比缩放配合SetWindowExt/SetViewportExt使用我在多个项目里实际对比过处理文本类打印用MM_TWIPS最稳因为字体磅值Point Size可以直接与TWIP换算设置字体时几乎不用额外计算。比如你想打印一个12磅的字体直接用CreateFont(240, ...)就行因为12磅 12 × 20 240 TWIP。如果是工程图纸、地图这类需要精确到毫米或者更小单位的MM_HIMETRIC更合适。提示修改映射模式的代码要放在OnBeginPrinting或者OnPreparePrinting里不要放在OnPrint里每次打印都改。一个打印任务的映射模式应该是全流程一致的中途改模式会导致坐标错乱。1.3 物理尺寸与打印机DPI的转换关系即使选定了映射模式打印仍然需要你知道一个关键参数打印机的物理DPI。这个可以从CDC::GetDeviceCaps拿到int dpiX pDC-GetDeviceCaps(LOGPIXELSX); int dpiY pDC-GetDeviceCaps(LOGPIXELSY);拿到DPI之后你就可以做一件非常重要的事计算打印纸的物理可绘制区域。假设纸张是A4210mm × 297mm打印机的物理DPI是600那么整张纸的像素尺寸大约是宽210 ÷ 25.4 × 600 ≈ 4961 像素高297 ÷ 25.4 × 600 ≈ 7016 像素但这只是纸张的物理尺寸实际可绘制区域还要扣除打印机的不可打印边距通常通过GetDeviceCaps(PHYSICALOFFSETX)和PHYSICALOFFSETY获取。如果不扣掉这个边距内容很可能被打印机的物理裁剪吃掉一部分尤其是一些老式激光打印机上下边距能占到好几毫米。这一节总结成一句话打印的第一步不是写绘图代码而是统一坐标系搞清楚设备的能力边界。2. 从CPrintDialog到OnDraw一条路径吃遍单页打印单页打印是MFC打印功能里最简单的场景但也最容易被写得“僵硬”——很多人直接在OnPrint里写死所有绘制逻辑换一台打印机就出问题。我建议把单页打印的流程拆成三段每段各司其职。2.1 弹出打印对话框并获取DC在MFC单文档/多文档架构下框架已经帮你处理了CPrintDialog的弹出你只需要处理OnPreparePrinting。但如果你在写无文档的独立工具类或者对话框程序就需要自己在代码里创建打印DCCPrintDialog dlg(FALSE, PD_ALLPAGES | PD_NOPAGENUMS | PD_HIDEPRINTTOFILE); if (dlg.DoModal() IDOK) { CDC dc; dc.Attach(dlg.GetPrinterDC()); // 现在 dc 就是有效的打印机DC // ... 进行打印任务 dc.DeleteDC(); }这里有一个细节GetPrinterDC()返回的DC是HDC句柄用它创建CDC对象之后用完一定要DeleteDC()而不是ReleaseDC()。因为打印机的DC是独立创建的不像屏幕DC那样隶属某个窗口。ReleaseDC只适用于从GetDC获取的屏幕DC用错会导致GDI泄漏这个问题在长时间运行的进程里尤其致命。2.2 打印任务的完整生命周期一个打印任务从开始到结束必须遵循一套严格的DC状态转换DOCINFO di { sizeof(DOCINFO), _T(MyPrintJob), NULL, NULL, 0 }; if (dc.StartDoc(di) 0) { // 启动打印失败了可能是驱动异常或打印机离线 return; } dc.StartPage(); // 在这里进行实际的绘制 dc.EndPage(); dc.EndDoc();这段代码看起来简单但顺序绝对不能乱。我曾经见过有人把StartPage和EndPage漏掉一个结果有的打印机直接不出纸有的打印机出一张白纸——因为页面没有正确提交。更隐蔽的问题是StartDoc和EndDoc必须成对出现StartPage和EndPage也必须成对出现。如果页面中间发生异常必须在catch块里调用AbortDoc而不是EndDoc否则打印队列里会残留一个“未完成的任务”后续任务都会被卡住。2.3 复用OnDraw代码的边界MFC框架的CView::OnPrint默认实现是这样的逻辑打印机DC和屏幕DC的映射模式如果一致就直接调用OnDraw。但实际项目中屏幕DC往往还是MM_TEXT打印机DC已经被你改成了MM_TWIPS这时候直接复用OnDraw就会出问题。我的处理方式是把真正的绘图逻辑抽成一个独立函数接收CDC*和打印矩形里面只用逻辑坐标绘图void CMyView::RenderReport(CDC* pDC, CRect rcPage) { // 这里只做排版绘制不关心是屏幕还是打印机 pDC-SetBkMode(TRANSPARENT); pDC-TextOut(rcPage.left, rcPage.top, _T(Report Header)); // 其他绘制... }屏幕预览时调用RenderReport(screenDC, screenRect)打印时调用RenderReport(printerDC, pageRect)。这样做的好处是绘制逻辑只有一份预览和打印永远一致。强行复用OnDraw反而会因为坐标体系不同而出现“预览正常打印错位”的问题。3. 多页样本打印分页计算与页面循环的控制点多页打印是MFC打印功能里最容易出乱子的部分。不少人遇到过表格数据明明有50行打印出来却只有第一页有内容后面全是空白或者反过来内容打印到了页面的边缘被拦腰截断。这背后的核心问题就两个框架如何控制打印循环以及每页该打印哪些内容。3.1 CPrintInfo如何驱动分页MFC的打印循环是由框架控制的它依赖于CPrintInfo对象里的几个关键字段字段作用设置时机m_nCurPage当前要打印的页码框架赋值m_nMaxPage总页数在OnPreparePrinting里设置m_pPD指向打印对话框对象框架传入m_nNumPreviewPages预览时显示的页数框架管理m_bContinuePrinting是否继续打印在OnPrepareDC里控制框架的循环逻辑是这样的先调用OnPreparePrinting然后调用OnBeginPrinting接着进入循环——每打印一页之前先调用OnPrepareDC并检查m_bContinuePrinting是否为TRUE如果是就调用OnPrint然后页数加一继续下一轮。所以控制多页打印的“总开关”其实是m_bContinuePrinting而不是m_nMaxPage。m_nMaxPage只是告诉框架一个上限值真正的终止条件取决于m_bContinuePrinting在某一轮循环里被设为FALSE。3.2 动态计算总页数从数据量推导在OnPreparePrinting里设置页数是静态的做法适用于数据量已知的场景BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { int pageCount (m_dataCount ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; pInfo-SetMaxPage(pageCount); return DoPreparePrinting(pInfo); }但更稳妥的方式是在OnPrepareDC里动态判断“还有没有内容要打印”void CMyView::OnPrepareDC(CDC* pDC, CPrintInfo* pInfo) { CView::OnPrepareDC(pDC, pInfo); if (pInfo pInfo-m_bCurPage) // 正在打印而不是屏幕绘制 { if (当前页要打印的起始行 m_dataCount) pInfo-m_bContinuePrinting FALSE; } }这个写法比死记页数要可靠得多。特别是数据结构是链表、树这类动态容器时先算总页数反而费劲不如每次取一批数据取完了就终止。3.3 每页内容渲染的偏移控制多页打印里每一页都要移动“绘制起点”。我在处理这种需求时通常会维护一个“当前页首行索引”然后在OnPrint里根据这个索引渲染本页内容void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { int startRow (pInfo-m_nCurPage - 1) * ROWS_PER_PAGE; int endRow min(startRow ROWS_PER_PAGE, m_dataCount); CRect rcPage 获取可打印区域矩形(pDC); for (int i startRow; i endRow; i) { DrawRow(pDC, rcPage, i, i - startRow); } }这里要注意一点页面的行数计算不是简单的“总行数 ÷ 每页行数”因为最后一页可能只有几行而且行高可能会因为数据内容动态变化比如某一行文字过多自动换行占了两行的高度。我的处理方法是写一个CalcRowHeight函数先累加判断本页放不下了就换页。多页打印的本质就是框架管“翻页循环”你管“每页的数据切片”。4. 打印预览的坑为什么预览和打印总对不上“预览完美打印翻车”——这应该是MFC打印功能里被吐槽最多的一个问题。很多人花大量时间去调打印绘制代码但问题其实出在预览框架的坐标处理上。4.1 预览的缩放机制MFC的打印预览并不是“直接拿打印DC画到屏幕上”而是走了一条完全不同的路径框架创建了一个特殊的预览DC这个DC把打印机的逻辑坐标等比缩放后映射到屏幕窗口上。为了保证“所见即所得”预览DC使用的映射模式必须和真实打印机DC保持一致。导致预览和打印不一致的常见原因包括在OnPrint里临时改变了映射模式或视口原点但预览时走的是另一条代码路径用的字体单位是屏幕像素而不是点阵单位TWIP/毫米使用了依赖具体DC分辨率的位图绘制导致预览和打印的缩放系数不同。4.2 让预览和打印真正一致的检查清单我在排查预览/打印不一致问题时有一套固定的自查顺序1. 检查映射模式打印开始前设一次预览路径也同样设置 2. 检查可打印区域预览时用的矩形要和打印机可打印区域一致不能取整个纸张尺寸 3. 检查字体创建必须单位一致统一用点阵Point计算创建而不是像素 4. 检查绘制缩放涉及位图或自定义绘图时ScaleMode是否一致 5. 检查DC缓存是否有DC状态被错误缓存导致第二次绘制用了旧参数。4.3 关于“所见即所得”的一个现实妥协有一点我必须实话实说在MFC框架里追求像素级完美的预览一致性性价比很低。打印机的实际输出受驱动、纸张、墨水/碳粉状态影响屏幕和纸张的介质差异本身就决定了不可能100%一致。我的做法是把预览的目标定为“结构一致、布局一致、换页一致”而不是颜色亮度上的绝对一致。也就是说预览里文本不会错位、分页位置和真实打印一致表格线粗细比例在屏幕上看起来舒服打印时也不突兀预览显示的页数与真实打印页数一致。做到这三点交付给用户已经完全可以接受了。想做到这一点核心就是让预览和打印共用同一套“逻辑坐标系”和同一套“绘制代码”这一点我在前两节反复强调过。只要映射模式统一预览和打印差异天然就会缩到很小。5. 位图和彩色输出的优化消除锯齿与颜色管理如果你的打印功能里不只是文本还涉及Logo、图片、彩色图表那问题就复杂一层了。这里最容易踩的坑是屏幕上清晰的图片打印出来全是锯齿和色块。5.1 屏幕位图直接打印为什么糊原因不复杂屏幕位图比如一个CBitmap加载的PNG通常分辨率是72或96 DPI而打印机的物理分辨率是300甚至600 DPI。打印驱动默认使用最近邻插值把低分辨率位图放大到打印分辨率表现就是明显的锯齿和马赛克。还有一种情况是位图颜色格式不匹配。屏幕常用的32位带Alpha通道的位图直接绘制到打印机DC上时某些驱动会错误处理Alpha通道导致图片周围出现诡异的黑边或白边。5.2 高质量的位图缩放方案我在项目里验证过几种方案最稳定可靠的是使用StretchDIBits配合高质量插值模式。关键点在于设置SetStretchBltModepDC-SetStretchBltMode(HALFTONE); pDC-SetBrushOrgEx(0, 0, NULL);HALFTONE模式在放大时会对像素进行混合处理效果比默认的COLORONCOLOR好得多几乎能达到视觉上“平滑放大”的效果。但这也只是治标。如果原图本身就只有72 DPI再怎么插值也只是“模糊的平滑”不可能变清晰。所以我在处理需要打印的素材时有一条铁律Logo、印章类图片必须提供矢量格式EMF/WMF矢量不会失真如果没有矢量格式至少要有300 DPI以上的位图原件截图类素材打印前人工检查分辨率是否满足输出尺寸要求。5.3 用增强元文件解决缩放失真如果Logo没有矢量版还有一个变通方案用CMetaFileDC先把绘制过程记录下来再按需要缩放播放。这个方案的精妙之处在于记录的是GDI绘图指令而不是像素所以在打印机上重放时会按照打印机的实际分辨率重新光栅化比直接贴图清晰得多。CMetaFileDC metaDC; metaDC.CreateEnhanced(NULL, NULL, NULL, _T(LogoMetaFile)); CRect rcLogo(0, 0, 1000, 1000); metaDC.SetMapMode(MM_ISOTROPIC); metaDC.SetWindowExt(1000, 1000); metaDC.SetViewportExt(1000, 1000); // 在这里用GDI命令绘制Logo比如Ellipse、LineTo、TextOut等 metaDC.Ellipse(100, 100, 900, 900); HENHMETAFILE hMeta metaDC.CloseEnhanced(); // 打印时播放 pDC-PlayMetaFile(hMeta, targetRect);这样做的好处是图形成本被转移到了矢量层面无论打印机分辨率多高边缘都是平滑的。我在处理客户提供的模糊Logo时就是用这个方式在PS里重绘成矢量后再通过PlayMetaFile输出的。虽然前期多花点时间但打印效果完全上了一个档次。6. 打印模块交付前必须处理的三类隐患这一节讲的不是“怎么实现功能”而是“怎么让功能稳定”。很多MFC打印功能在开发机上一切正常部署到客户机器上就频繁出问题根本原因往往不在打印逻辑本身而在于一些容易被忽略的“环境因素”。6.1 GDI资源泄漏静默的打印机杀手打印功能里最容易泄漏GDI资源的地方是字体对象。每次循环创建CFont、绘制完SelectObject回去之后忘记DeleteObject短时间打印一两页没问题但长时间批量打印几百个文件系统GDI资源会慢慢耗尽最终所有程序都画不出界面。我的习惯写法是配合CPrintInfo的生命周期管理资源CFont font; font.CreateFont(...); CFont* pOldFont pDC-SelectObject(font); // 绘制... pDC-SelectObject(pOldFont); // font对象在本作用域结束时自动释放但有一点要注意如果CFont是在循环里反复创建的一定要在每轮循环结束时主动释放否则作用域方式会在循环里累积。GDI资源不像内存那样频繁触发垃圾回收它更接近“不主动释放就不释放”。6.2 中文字体的坑为什么打印出来是方块MFC打印中文出现方块90%的原因是在打印机DC上没有正确设置字体。屏幕DC默认有一个系统字体可以显示中文但打印机DC的默认字体往往不支持中文。解决方法是显式指定中文字体。我在打印功能里统一使用“Microsoft YaHei”或者“SimSun”作为中文回退字体LOGFONT lf {0}; lf.lfHeight -MulDiv(12, pDC-GetDeviceCaps(LOGPIXELSY), 72); lf.lfCharSet DEFAULT_CHARSET; _tcscpy_s(lf.lfFaceName, _T(Microsoft YaHei)); CFont font; font.CreateFontIndirect(lf);注意lf.lfHeight是用MulDiv计算的这样才能保证字号与打印机DPI匹配不会出现“屏幕上12磅打印出来像6磅”的问题。6.3 DPI适配高分辨率下的坐标错乱如果你的程序需要在Windows 10/11的高DPI显示器上运行并且系统缩放比例设置成了125%、150%甚至200%那么打印坐标可能会被系统缩放影响。最典型的现象是在150%缩放的机器上打印内容整体偏移到了纸张右下角而且打印内容比预期大了一圈。这是因为Windows的DPI虚拟化机制把打印机的物理坐标也进行了缩放。解决办法是在程序启动时声明DPI感知。标准的做法是在manifest文件里设置dpiAwarenessPerMonitorV2/dpiAwareness或者调用SetProcessDPIAware()仅Windows Vista以上且需要在创建任何窗口之前调用。如果是MFC项目也可以在InitInstance()最前面加上AfxOleInit()之前的地方调用系统API。这块如果不处理打印问题会演变成“所有用户在高分屏上都打印异常”的尴尬局面。6.4 打印模块的封装思路从零散代码到可复用组件最后说说我在项目里的一个长远做法不要把所有打印代码都堆在CView派生类里建议抽出一个独立的CPrintHelper类专门负责打印DC的创建与释放字体、画笔、画刷等GDI对象的生命周期管理映射模式与可打印区域的统一设置分页计算逻辑每一“页”的绘制委托。这样做的好处非常明显当业务方要求“新增一种报表格式”时我只需要新增一个渲染回调而不是复制粘贴几百行打印代码再改几个参数。MFC打印本身不复杂但一旦塞进业务代码里几个功能叠加起来就会变成谁也改不动的“屎山”。组件化之后每个打印任务都是相对独立的小模块调试和维护成本能降一大截。我实际经历过的最痛苦一次维护就是在一个遗留项目里找一段“只在打印时才会触发”的崩溃最后定位到是两个打印功能共用了同一个静态CFont对象一个功能释放了另一个功能还在用。从那以后我就强行规定打印模块内部的状态必须全部封装在实例对象里禁止使用静态共享资源。这一点现在也一并分享给你。本文还有配套的精品资源点击获取
返回列表