简介:这是一份面向Windows C++开发者的MFC打印功能实现示例工程,适合正在学习MFC框架、需要为桌面应用添加打印与打印预览能力的初中级开发者。资源围绕CView派生类展开,涵盖打印机设备上下文创建、StartDoc/StartPage/EndPage/EndDoc打印作业流程、OnDraw适配打印机DC、CPrintDialog打印对话框、CPreviewDC打印预览以及分页处理、打印选项、错误处理与性能优化等关键知识点,并配有可直接参考的代码片段。压缩包共50个文件,约22.82MB,包含3个cpp源文件与5个h头文件承载核心逻辑,2个rc资源脚本、1个ico图标及res资源目录用于界面,vcxproj、sln、filters等工程配置便于直接打开编译,另有exe可执行文件与pdb、ilk、obj等调试中间文件辅助运行验证。目前已有346人学习下载,可作为MFC打印模块的入门模板与排错参考。
1. 从一张报表说起:MFC打印功能到底解决什么问题
做过桌面端工控或管理系统的开发者大概都遇到过这种需求:界面上跑得好好的数据表格,用户点一下“打印”,要么弹出一张白纸,要么排版全乱,要么干脆把整个窗口截图丢上去。我接手过一个模拟项目,里面有个报表模块,数据量不大,但打印出来页码错位、表头丢失,用户直接投诉到项目组。后来翻源码才发现,打印这块用的是最原始的OnPrint硬编码,连CPrintDialog都没用明白。
这份“实现MFC打印功能”的资源,核心就是围绕 MFC 的打印体系,把CDC、CPrintDialog、OnPrepareDC、OnPrint、OnDraw这几个关键点串起来,给出一个能直接跑、能改、能复用的打印框架。它适合两类人:一类是刚接触 MFC 打印、被StartDoc/EndDoc绕晕的新手;另一类是想把打印模块从业务代码里解耦出来、做二次开发的老手。打印功能在 MFC 里不算新话题,但能把“打印预览 + 分页 + 缩放 + 页眉页脚”讲清楚、代码还能直接抄的,确实不多。
2. 打印框架拆解:从 CPrintDialog 到 OnPrint 的完整链路
2.1 打印流程的四个阶段
MFC 的打印不是一步到位的,它把整个动作拆成了四个阶段:打印对话框选择、打印设备上下文创建、逐页绘制、文档结束。很多新手一上来就写OnPrint,结果发现CDC指针是空的,或者打印出来只有一页,就是因为没搞清楚这四个阶段的先后顺序。
先看打印对话框。MFC 封装了CPrintDialog,它对应 Win32 的PrintDlgAPI。调用DoModal()之后,用户选择打印机、纸张、方向,这些信息会保存在CPrintDialog对象里。关键参数是PD_RETURNDC和PD_USEDEVMODECOPIESANDCOLLATE,前者让对话框返回一个已经创建好的设备上下文句柄,后者处理多份打印和逐份打印的逻辑。
// 创建打印对话框,返回打印机DC CPrintDialog dlg(FALSE, PD_ALLPAGES | PD_RETURNDC | PD_USEDEVMODECOPIESANDCOLLATE, this); if (dlg.DoModal() != IDOK) return; // 获取打印机DC,注意这里拿到的是HDC,需要Attach到CDC HDC hdc = dlg.GetPrinterDC(); if (hdc == NULL) return; CDC dc; dc.Attach(hdc);这段代码里,FALSE表示这是打印对话框而不是打印设置对话框;PD_RETURNDC是关键,没有它就拿不到打印机 DC。GetPrinterDC()返回的HDC必须Attach到CDC对象上,否则后面StartDoc会失败。我见过有人直接用dlg.GetPrinterDC()然后强转,结果在 Win10 上直接崩,就是因为没走Attach这一步。
2.2 文档开始与结束:StartDoc / EndDoc 的配对
拿到 DC 之后,不能直接画,必须先告诉系统“我要开始打印一份文档了”。这就是StartDoc的作用。它接收一个DOCINFO结构,里面填文档名和输出端口。DOCINFO的cbSize必须赋值,否则在部分打印机驱动上会返回-1。
DOCINFO di; memset(&di, 0, sizeof(DOCINFO)); di.cbSize = sizeof(DOCINFO); di.lpszDocName = _T("报表打印"); di.lpszOutput = NULL; // 输出到打印机而不是文件 if (dc.StartDoc(&di) < 0) { dc.Detach(); return; } // 开始第一页 dc.StartPage(); // 这里执行实际的绘制逻辑 OnPrint(&dc, ...); // 结束第一页 dc.EndPage(); // 结束整个文档 dc.EndDoc(); dc.Detach();StartDoc返回负数表示失败,常见原因是打印机脱机或者驱动异常。StartPage和EndPage必须成对出现,每一页都要包在这两个调用之间。EndDoc之后一定要Detach,否则CDC析构时会重复释放 HDC,导致 GDI 对象泄漏。这个坑我在早期项目里踩过,打印十几次之后程序直接卡死,查了半天才发现是Detach没调。
2.3 OnPrint 与 OnDraw 的分工
MFC 的CView类提供了OnPrint和OnDraw两个虚函数。OnDraw负责屏幕绘制,OnPrint负责打印绘制。默认情况下,OnPrint会调用OnDraw,但两者的CDC不同:屏幕的CDC是内存 DC 或窗口 DC,打印的CDC是打印机 DC。
很多开发者图省事,直接在OnDraw里写所有绘制逻辑,打印时也走同一套代码。这样做的问题是:屏幕分辨率和打印机分辨率差异巨大,屏幕上 10 像素的线,打印出来可能细得看不见。正确的做法是在OnPrint里根据打印机 DPI 做缩放。
void CReportView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 获取打印机物理参数 int nHorzSize = pDC->GetDeviceCaps(HORZSIZE); // 毫米 int nVertSize = pDC->GetDeviceCaps(VERTSIZE); int nHorzRes = pDC->GetDeviceCaps(HORZRES); // 像素 int nVertRes = pDC->GetDeviceCaps(VERTRES); int nLogPixelsX = pDC->GetDeviceCaps(LOGPIXELSX); int nLogPixelsY = pDC->GetDeviceCaps(LOGPIXELSY); // 计算缩放比例,把逻辑坐标映射到打印机坐标 double scaleX = (double)nHorzRes / nHorzSize / nLogPixelsX * 25.4; double scaleY = (double)nVertRes / nVertSize / nLogPixelsY * 25.4; pDC->SetMapMode(MM_ANISOTROPIC); pDC->SetWindowExt(1000, 1000); // 逻辑单位 pDC->SetViewportExt((int)(1000 * scaleX), (int)(1000 * scaleY)); // 调用实际绘制 DrawReportContent(pDC, pInfo); }这里用MM_ANISOTROPIC映射模式,把逻辑坐标和打印机物理坐标解耦。SetWindowExt设成 1000×1000 是常见做法,表示逻辑上 1000 单位对应一页宽度。SetViewportExt根据打印机实际 DPI 计算,保证不同打印机输出尺寸一致。GetDeviceCaps的参数含义:HORZSIZE是纸张物理宽度(毫米),HORZRES是水平像素数,LOGPIXELSX是每英寸逻辑像素数。这三个值配合,就能算出精确的缩放系数。
2.4 分页控制:OnPrepareDC 与 m_rectDraw
分页是打印功能里最容易翻车的地方。MFC 通过CPrintInfo结构管理页码和绘制区域。m_rectDraw表示当前页可打印区域,m_nCurPage是当前页码,m_nNumPreviewPages控制预览页数。
OnPrepareDC在每一页绘制前被调用,用来调整 DC 的视口原点。如果打印内容超过一页,需要在OnPrint里判断是否要调用pInfo->SetMaxPage()增加页数。
void CReportView::OnPrepareDC(CDC* pDC, CPrintInfo* pInfo) { CView::OnPrepareDC(pDC, pInfo); if (pInfo == NULL) return; // 设置每页可打印区域 pDC->SetMapMode(MM_TEXT); pInfo->m_rectDraw.SetRect(0, 0, pDC->GetDeviceCaps(HORZRES), pDC->GetDeviceCaps(VERTRES)); // 根据当前页码计算绘制偏移 int nPageHeight = pInfo->m_rectDraw.Height(); pDC->SetViewportOrg(0, -nPageHeight * (pInfo->m_nCurPage - 1)); }SetViewportOrg的第二个参数是负值,表示把绘制原点向上移动。第 1 页偏移 0,第 2 页偏移 -页高,第 3 页偏移 -2×页高,以此类推。这样在OnPrint里就可以用同一套坐标画所有内容,MFC 会自动把超出当前页的部分裁掉。m_rectDraw必须在OnPrepareDC里设置,否则打印区域会是默认的 0×0,什么都画不出来。
3. 打印预览与分页实战:让输出和屏幕一致
3.1 打印预览的两种实现方式
MFC 提供了两种打印预览:一种是基于CPreviewView的框架级预览,另一种是手动创建CDC模拟预览。框架级预览最省事,只需要在CView派生类里调用OnFilePrintPreview(),MFC 会自动接管。
// 在视图类中响应“打印预览”菜单 void CReportView::OnFilePrintPreview() { // 创建预览窗口 CPreviewView* pPreview = new CPreviewView(this); pPreview->Create(NULL, NULL, AFX_WS_DEFAULT_VIEW, CRect(0, 0, 0, 0), this, AFX_IDW_PANE_FIRST, NULL); pPreview->SetPrintView(this); pPreview->OnFilePrintPreview(); }CPreviewView会调用OnPrint两次:一次用于屏幕预览,一次用于实际打印。区别在于CDC不同。预览时CDC是内存 DC,打印时是打印机 DC。所以OnPrint里的代码必须对两种 DC 都兼容,不能硬编码打印机特有的属性。
3.2 分页计算与页码显示
分页的核心是计算内容总高度,然后除以每页可打印高度。常见做法是在OnPrint里先测量内容高度,再设置m_nNumPreviewPages。
void CReportView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 测量内容总高度 int nTotalHeight = MeasureContentHeight(pDC); int nPageHeight = pInfo->m_rectDraw.Height(); int nPageCount = (nTotalHeight + nPageHeight - 1) / nPageHeight; // 设置总页数 pInfo->SetMaxPage(nPageCount); // 绘制当前页内容 DrawPageContent(pDC, pInfo->m_nCurPage); // 绘制页眉页脚 DrawHeaderFooter(pDC, pInfo->m_nCurPage, nPageCount); }MeasureContentHeight需要遍历所有数据行,累加行高。如果数据量很大,这个函数会被调用多次,建议加缓存。SetMaxPage必须在OnPrint里调用,不能在OnPrepareDC里调,否则预览时页码显示不对。页眉页脚用pDC->TextOut直接画,注意坐标要基于m_rectDraw计算,不要用固定值。
3.3 打印参数配置表
| 参数 | 含义 | 常见取值 |
|---|---|---|
PD_RETURNDC | 返回打印机 DC | 必选 |
PD_ALLPAGES | 打印所有页 | 默认 |
PD_SELECTION | 打印选中内容 | 按需 |
PD_PAGENUMS | 打印指定页 | 配合nFromPage/nToPage |
m_nCurPage | 当前页码 | 从 1 开始 |
m_rectDraw | 可打印区域 | 由OnPrepareDC设置 |
m_bPreview | 是否预览模式 | TRUE/FALSE |
这张表里的参数在CPrintInfo和CPrintDialog里都能找到。PD_PAGENUMS需要配合dlg.m_pd.nFromPage和dlg.m_pd.nToPage使用,否则打印范围无效。m_bPreview在OnPrint里可以用来区分预览和实际打印,比如预览时画虚线边框,打印时不画。
4. 避坑与排查:打印功能最常见的五个翻车点
4.1 打印出来只有一页
现象:数据明明有几十行,打印出来只有第一页,后面的内容不见了。
原因:OnPrint里没有调用SetMaxPage,MFC 默认只打印一页。或者OnPrepareDC里没有设置m_rectDraw,导致可打印区域为 0。
解决:在OnPrint开头计算总页数并调用pInfo->SetMaxPage(nPageCount)。同时检查OnPrepareDC里是否设置了m_rectDraw,确保它等于打印机可打印区域。
4.2 打印预览正常,实际打印错位
现象:预览时排版整齐,点打印后内容偏移或缩放异常。
原因:预览和打印的CDC分辨率不同,代码里用了屏幕 DPI 硬编码。比如SetViewportExt里直接写死 96 DPI,打印机是 300 DPI 时就会缩小。
解决:所有坐标计算都基于GetDeviceCaps动态获取,不要写死数值。LOGPIXELSX和LOGPIXELSY在预览和打印时返回不同值,必须分别处理。
4.3 GDI 对象泄漏导致程序卡死
现象:连续打印多次后,程序越来越慢,最后无响应。
原因:CDC对象没有Detach,或者StartDoc失败后没有释放 DC。每次打印都会创建一个新的 HDC,不释放就会耗尽 GDI 句柄。
解决:StartDoc返回负数时立即Detach并返回。EndDoc之后也必须Detach。可以用GetGuiResources监控 GDI 对象数量,超过 5000 就要警惕。
4.4 页眉页脚遮挡正文
现象:页眉画在正文上方,但正文第一行被盖住了。
原因:页眉和正文用了同一套坐标,没有留出边距。
解决:在m_rectDraw基础上减去页眉页脚高度,正文从m_rectDraw.top + nHeaderHeight开始画。页脚同理,从m_rectDraw.bottom - nFooterHeight开始。
4.5 多份打印时页码重复
现象:设置打印 3 份,每份的页码都是从 1 开始,但总页数显示不对。
原因:PD_USEDEVMODECOPIESANDCOLLATE没有设置,或者m_nCurPage在多份打印时没有重置。
解决:在CPrintDialog构造函数里加上PD_USEDEVMODECOPIESANDCOLLATE,让打印机驱动处理多份逻辑。如果驱动不支持,需要在OnPrint里手动循环,每次循环前重置m_nCurPage。
5. 二次开发改造:把打印模块抽成独立类
5.1 从 CView 派生类中解耦
原始代码把打印逻辑全写在CReportView里,业务一改就要动视图类。我的做法是抽一个CPrintHelper类,把StartDoc、EndDoc、分页计算、页眉页脚全部封装进去。视图类只负责提供数据,打印细节交给 Helper。
class CPrintHelper { public: CPrintHelper(); ~CPrintHelper(); // 初始化打印,返回FALSE表示用户取消 BOOL InitPrint(CWnd* pParent); // 执行打印,pData为业务数据 void DoPrint(CWnd* pParent, const CPrintData& data); // 打印预览 void DoPreview(CWnd* pParent, const CPrintData& data); private: CDC m_dc; CPrintDialog m_dlg; int m_nPageCount; void DrawPage(CDC* pDC, int nPage); void DrawHeader(CDC* pDC, int nPage); void DrawFooter(CDC* pDC, int nPage); };CPrintData是一个数据结构,包含表头、行数据、列宽、字体等信息。视图类只需要填充这个结构,然后调用DoPrint或DoPreview。这样打印模块可以复用到任何需要打印的视图里,不用重复写StartDoc和分页逻辑。
5.2 支持自定义纸张和边距
很多项目需要打印到特定尺寸的纸张,比如发票、标签。MFC 默认使用打印机设置的纸张,但可以通过DEVMODE结构动态修改。
// 修改纸张大小和边距 DEVMODE* pDevMode = (DEVMODE*)GlobalLock(m_dlg.m_pd.hDevMode); if (pDevMode != NULL) { pDevMode->dmPaperSize = DMPAPER_A4; // A4纸 pDevMode->dmOrientation = DMORIENT_LANDSCAPE; // 横向 pDevMode->dmFields |= DM_PAPERSIZE | DM_ORIENTATION; GlobalUnlock(m_dlg.m_pd.hDevMode); } // 重置打印机DC m_dlg.m_pd.hDC = m_dlg.CreatePrinterDC();dmPaperSize可以设成DMPAPER_A4、DMPAPER_LETTER等标准值,也可以设成自定义值。dmFields必须标记哪些字段被修改,否则驱动会忽略。修改完DEVMODE后要重新调用CreatePrinterDC,否则 DC 还是旧的配置。
5.3 打印到文件与 PDF 输出
如果用户没有物理打印机,可以把打印结果输出到文件。DOCINFO的lpszOutput字段指定输出文件名,StartDoc就会把内容写到文件而不是打印机。
DOCINFO di; memset(&di, 0, sizeof(DOCINFO)); di.cbSize = sizeof(DOCINFO); di.lpszDocName = _T("报表输出"); di.lpszOutput = _T("C:\\output.prn"); // 输出到文件 if (m_dc.StartDoc(&di) < 0) return;输出到.prn文件后,可以用第三方工具转成 PDF。如果项目允许引入外部库,也可以直接用 PDF 库生成,但那就脱离 MFC 打印体系了。我的习惯是保留 MFC 打印作为默认方案,PDF 输出作为可选功能,通过配置文件切换。
5.4 一个验证打印正确性的小技巧
打印功能最难测的是“输出是否和预期一致”。我的做法是写一个DumpPrintContent函数,把OnPrint里实际绘制的文本和坐标输出到日志文件,然后和设计稿对比。
void CPrintHelper::DumpPrintContent(CDC* pDC, int nPage) { CString strLog; strLog.Format(_T("Page %d, Rect: %d,%d,%d,%d\n"), nPage, m_rectDraw.left, m_rectDraw.top, m_rectDraw.right, m_rectDraw.bottom); OutputDebugString(strLog); // 记录每个文本块的坐标 for (int i = 0; i < m_arrText.GetSize(); i++) { strLog.Format(_T("Text[%d]: %s at (%d,%d)\n"), i, m_arrText[i].strText, m_arrText[i].pt.x, m_arrText[i].pt.y); OutputDebugString(strLog); } }这个函数在 Debug 模式下输出到OutputDebugString,用 DebugView 工具就能看到。对比日志里的坐标和设计稿,能快速定位偏移问题。从那以后我每次改打印逻辑,都强制走一遍这个 Dump 流程,确认坐标没跑偏再提交代码。希望这个思路能帮到你,少走一些我当年踩过的弯路。
本文还有配套的精品资源,点击获取