简介:这份资源面向具备一定C++与Windows开发基础的MFC学习者,聚焦在应用程序中集成打印与打印预览功能这一常见但易踩坑的环节。内容围绕CView派生类的打印机制展开,涵盖打印机设备上下文创建、打印作业启停、页面布局设置、OnDraw适配打印机DC、分页处理以及打印对话框与预览实现等关键知识点,并配有可直接参考的代码片段,帮助读者理解从屏幕绘制到打印输出的完整流程。资源包共50个文件,以h头文件、cpp源文件、rc资源脚本、vcxproj工程文件及sln解决方案为主,另含exe可执行程序、pdb调试符号、manifest清单与日志等编译中间产物,整体约22.82MB,工程结构完整,可直接打开编译运行对照调试。目前已有346人学习下载,适合需要为MFC项目补充打印模块、或希望系统梳理打印与预览实现思路的开发者参考借鉴。
1. 从一张没人愿意接的工单说起:MFC打印功能到底难在哪
接手过一个老工控上位机的维护,界面是标准的 MFC 单文档结构,功能跑得挺稳,唯独打印这块一直空着。前一位维护者留了句“打印太麻烦,让客户截图贴 Word 里打”,客户当然不干。我花了两天把 MFC 的打印链路重新捋了一遍,才发现它其实不是难,而是绕——绕在设备上下文(DC)的切换、打印机的分页逻辑、以及预览和实际输出共用一套绘制代码这三件事上。MFC 打印功能说白了就是让 OnDraw 里那套 GDI 绘制代码,既能画到屏幕上,也能画到打印机上,还能画到预览窗口里。它解决的是“同一份数据,三种输出目标”的问题,适合手里有 MFC 老项目、需要补打印模块的桌面端开发者。下面把我踩过的路和能直接抄的代码摊开讲。
2. 打印功能的底层逻辑:为什么 MFC 要绕这么大一圈
2.1 屏幕 DC 和打印机 DC 的本质差异
很多人第一次写打印,习惯性地把屏幕上的CDC* pDC直接拿来用,结果要么打出来一片空白,要么尺寸完全对不上。原因在于屏幕 DC 和打印机 DC 是两种物理特性完全不同的设备上下文。屏幕的坐标单位是像素,分辨率通常 96 DPI,而打印机的坐标单位取决于驱动,常见 300 DPI 到 600 DPI,同一段逻辑坐标映射到物理设备上,比例差了三四倍。
MFC 用OnPrint和OnDraw的分离来应对这件事。框架在打印时会调用OnPrint,把打印机 DC 传进来,同时通过CPrintInfo结构体携带当前页码、打印范围等信息。如果你把所有绘制逻辑都塞在OnDraw里,框架在屏幕刷新和打印时会走同一条路径,但传入的 DC 不同,这就是“一份代码三处复用”的设计意图。
关键点在于映射模式。默认MM_TEXT下,一个逻辑单位等于一个设备像素,打印时这个“像素”是打印机的点,尺寸会严重偏小。常见做法是在OnPrint里设置MM_LOENGLISH或MM_HIMETRIC,让逻辑单位对应物理尺寸(0.01 英寸或 0.01 毫米),这样屏幕和打印机的输出比例才能一致。
// 在 OnPrint 中设置映射模式,保证打印尺寸可控 void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 保存原映射模式,避免影响后续绘制 int nOldMapMode = pDC->SetMapMode(MM_LOENGLISH); // MM_LOENGLISH 下 y 轴向上为正,需要翻转 pDC->SetViewportOrg(0, 0); pDC->SetWindowOrg(0, 0); // 调用统一的绘制函数,传入 DC 和打印信息 DrawContent(pDC, pInfo); pDC->SetMapMode(nOldMapMode); }这段代码的逻辑是:进入打印回调后先切换映射模式,让后续所有绘制命令都按物理尺寸解释。MM_LOENGLISH的好处是 1 个单位等于 0.01 英寸,A4 纸宽约 8.27 英寸,对应 827 个单位,算起来直观。参数pInfo里带着m_rectDraw,表示当前页可打印区域,绘制时应该以它为边界,而不是硬编码纸张尺寸。
2.2 打印流程的四个阶段与对应回调
MFC 的打印流程由框架驱动,开发者只需要在几个关键回调里填逻辑。整个流程分四步:启动打印、创建 DC、逐页绘制、结束清理。对应到代码里,OnPreparePrinting负责设置页数和打印范围,OnBeginPrinting创建打印专用的 GDI 资源(比如字体、画笔),OnPrint逐页绘制,OnEndPrinting释放资源。
新手最容易忽略的是OnPreparePrinting里的SetMaxPage。如果不设,框架默认只打一页,多页数据会被截断。我见过一个报表模块,数据明明有 200 行,打出来只有 40 行,查了半天发现是没设最大页数,框架只调了一次OnPrint。
BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { // 根据数据量计算总页数,每页固定行数 int nTotalLines = m_dataList.GetSize(); int nLinesPerPage = 45; // A4 纸在 MM_LOENGLISH 下大约能放 45 行 int nPageCount = (nTotalLines + nLinesPerPage - 1) / nLinesPerPage; // 设置打印范围,至少一页 pInfo->SetMinPage(1); pInfo->SetMaxPage(nPageCount > 0 ? nPageCount : 1); return DoPreparePrinting(pInfo); }这里SetMaxPage的参数就是总页数,框架据此决定调用OnPrint的次数。DoPreparePrinting会弹出打印对话框,让用户选打印机和页码范围。注意nLinesPerPage不是随便定的,它取决于映射模式、字体大小和页边距,需要根据实际排版微调。我一般会先打一页测试,数一下实际行数再回填这个值。
2.3 打印预览与打印的代码复用策略
打印预览是 MFC 送的一个便利:框架用屏幕 DC 模拟打印机 DC,把OnPrint的输出渲染到预览窗口。这意味着只要OnPrint写对了,预览基本不会出问题。但有个坑:预览时的映射模式和实际打印必须一致,否则预览看着正常,打出来比例不对。
常见做法是把所有绘制逻辑抽到一个独立函数,比如DrawPage(CDC* pDC, int nPage),OnDraw和OnPrint都调它。OnDraw里传屏幕 DC,OnPrint里传打印机 DC,通过pInfo->m_nCurPage决定画哪一页。这样屏幕显示、预览、实际打印三处共用同一套代码,改一处全生效。
void CMyView::OnDraw(CDC* pDC) { // 屏幕显示时,只画当前可见区域,不涉及分页 DrawPage(pDC, 0); // 0 表示屏幕模式,不分页 } void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 打印和预览时,按页码绘制 DrawPage(pDC, pInfo->m_nCurPage); } void CMyView::DrawPage(CDC* pDC, int nPage) { if (nPage == 0) { // 屏幕模式:直接画全部内容,依赖滚动条 DrawAllContent(pDC); } else { // 打印模式:只画第 nPage 页对应的数据段 int nStart = (nPage - 1) * 45; DrawContentRange(pDC, nStart, 45); } }这个结构的好处是职责清晰:OnDraw管屏幕,OnPrint管打印,DrawPage做分发。参数nPage为 0 时表示屏幕模式,大于 0 时表示打印页码。实际项目中,屏幕模式可能需要处理滚动和裁剪,打印模式需要处理分页边界,两者在DrawPage里分流,互不干扰。
3. 从零接入打印:一份可编译的 MFC 打印模块
3.1 工程配置与打印相关的类结构
假设你手里是一个标准的 MFC 单文档工程,视图类继承自CView。要接入打印,首先确认CView的派生类已经重写了OnDraw,因为打印框架默认会调用它。然后通过类向导添加OnPreparePrinting、OnBeginPrinting、OnPrint、OnEndPrinting四个虚函数的重写。如果用的是CScrollView,打印逻辑基本一致,但要注意滚动偏移在打印时应该忽略。
工程配置上不需要额外链接库,MFC 的打印支持是内置的。但有一个编译期注意点:如果项目用了 Unicode 字符集,打印文本时要用_T()宏包裹字符串,否则中文会乱码。另外,OnPrint的签名必须是void OnPrint(CDC* pDC, CPrintInfo* pInfo),参数类型不能改,否则框架不会调用。
// 头文件中声明重写函数 class CMyView : public CView { protected: virtual BOOL OnPreparePrinting(CPrintInfo* pInfo); virtual void OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo); virtual void OnPrint(CDC* pDC, CPrintInfo* pInfo); virtual void OnEndPrinting(CDC* pDC, CPrintInfo* pInfo); // 自定义绘制函数 void DrawPage(CDC* pDC, int nPage); void DrawContentRange(CDC* pDC, int nStart, int nCount); // 打印专用字体,在 OnBeginPrinting 中创建 CFont m_printFont; int m_nLinesPerPage; };类结构上,把打印专用的 GDI 对象作为成员变量,在OnBeginPrinting里创建,OnEndPrinting里销毁。这样避免每次OnPrint都重新创建字体,既浪费资源又可能导致 GDI 句柄泄漏。m_nLinesPerPage作为成员变量,方便在OnPreparePrinting和DrawContentRange之间共享。
3.2 OnBeginPrinting 里创建打印专用 GDI 资源
OnBeginPrinting在整个打印任务开始时调用一次,适合做资源初始化。这里创建字体、画笔、刷子等 GDI 对象,并计算每页能放多少行。计算行数时,用GetTextMetrics获取字体高度,再用可打印区域高度除以行高。
void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { // 创建打印专用字体,比屏幕字体稍大,保证清晰度 m_printFont.CreatePointFont(100, _T("宋体"), pDC); // 选入字体,测量行高 CFont* pOldFont = pDC->SelectObject(&m_printFont); TEXTMETRIC tm; pDC->GetTextMetrics(&tm); // 计算可打印区域能放多少行 // pInfo->m_rectDraw 是逻辑坐标下的可打印矩形 int nHeight = pInfo->m_rectDraw.Height(); m_nLinesPerPage = nHeight / (tm.tmHeight + tm.tmExternalLeading); // 恢复原字体 pDC->SelectObject(pOldFont); }CreatePointFont的第一个参数是字体大小的十分之一磅值,100 表示 10 磅。GetTextMetrics返回的tmHeight是字符高度,tmExternalLeading是行间距,两者相加才是实际行高。m_rectDraw在OnBeginPrinting时已经由框架根据打印机和纸张设置好了,直接用它算行数最准确。注意这里选入字体后要恢复,否则会影响后续绘制。
3.3 OnPrint 中按页绘制与坐标映射
OnPrint是核心,每页调用一次。里面要做三件事:设置映射模式、计算当前页的数据范围、调用绘制函数。映射模式建议用MM_LOENGLISH或MM_HIMETRIC,前者单位 0.01 英寸,后者 0.01 毫米,都比MM_TEXT更适合打印。
void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 设置映射模式为 0.01 英寸,y 轴向上 int nOldMode = pDC->SetMapMode(MM_LOENGLISH); // 选入打印字体 CFont* pOldFont = pDC->SelectObject(&m_printFont); // 计算当前页的数据起始索引 int nPage = pInfo->m_nCurPage; int nStart = (nPage - 1) * m_nLinesPerPage; // 绘制页眉 pDC->SetTextColor(RGB(0, 0, 0)); CString strHeader; strHeader.Format(_T("报表 第 %d 页"), nPage); pDC->TextOut(0, -100, strHeader); // y 为负表示向下 // 绘制正文内容 DrawContentRange(pDC, nStart, m_nLinesPerPage); // 恢复 GDI 对象 pDC->SelectObject(pOldFont); pDC->SetMapMode(nOldMode); }注意MM_LOENGLISH下 y 轴向上为正,所以TextOut的 y 坐标用负数表示向下偏移。页眉画在 -100 的位置,即距顶部 1 英寸。正文从nStart开始,画m_nLinesPerPage行。DrawContentRange里逐行TextOut,每行 y 坐标递减tmHeight。这里的关键参数是m_nLinesPerPage,它决定了分页边界,必须和OnPreparePrinting里算页数时用的值一致,否则会出现某页空白或数据截断。
3.4 打印预览的启用与调试技巧
打印预览不需要额外代码,MFC 框架自带。在菜单里加一个“打印预览”项,映射到CView::OnFilePrintPreview即可。调试预览时,常见问题是预览窗口一片空白,原因通常是OnPrint里没选入字体,或者映射模式设错了导致内容画到了可视区域外。
我一般会在OnPrint开头加一句TRACE输出当前页码和m_rectDraw的尺寸,在 Debug 模式下看输出窗口。如果页码对但内容不显示,检查m_rectDraw的坐标范围,确认绘制坐标落在矩形内。另一个技巧是临时把背景刷成浅色,比如pDC->FillSolidRect(pInfo->m_rectDraw, RGB(240, 240, 240)),这样能直观看到可打印区域的位置。
// 调试用:在 OnPrint 开头输出关键信息 #ifdef _DEBUG TRACE(_T("OnPrint: page=%d, rect=(%d,%d,%d,%d)\n"), pInfo->m_nCurPage, pInfo->m_rectDraw.left, pInfo->m_rectDraw.top, pInfo->m_rectDraw.right, pInfo->m_rectDraw.bottom); #endif这段TRACE只在 Debug 下生效,Release 编译时自动移除。输出窗口会显示每页的页码和可打印矩形,对照绘制坐标就能判断内容是否画偏。如果m_rectDraw的 bottom 是负数(MM_LOENGLISH下正常),说明映射模式已生效。
4. 打印模块的避坑清单:五个让我加班到凌晨的坑
4.1 现象:打印出来只有一页,后面数据全丢
原因:OnPreparePrinting里没有调用SetMaxPage,框架默认最大页数为 1,只调用一次OnPrint。解决:在OnPreparePrinting里根据数据总量和每页行数计算页数,显式调用pInfo->SetMaxPage(n)。注意SetMinPage也要设,通常为 1。
4.2 现象:预览正常,实际打印比例缩小或放大
原因:预览和打印用了不同的映射模式。预览时框架可能用屏幕 DC 的默认模式,打印时用了MM_LOENGLISH,两者单位不一致。解决:在OnPrint和OnDraw里统一设置映射模式,或者把映射模式设置放在DrawPage函数开头,确保两条路径一致。
4.3 现象:中文打印出来是乱码或问号
原因:项目是 Unicode 字符集,但TextOut传了 ANSI 字符串,或者字体不支持中文。解决:字符串用_T()包裹,字体创建时指定_T("宋体")或_T("微软雅黑")。如果用的是CString,确认它是以 Unicode 方式构造的。
4.4 现象:打印多页时,第二页开始内容偏移或重叠
原因:OnPrint里没有根据页码重置坐标原点,或者m_nLinesPerPage在OnPreparePrinting和OnPrint里计算不一致。解决:在OnPrint开头调用pDC->SetViewportOrg(0, 0)重置原点,并确保m_nLinesPerPage是成员变量,只在一处计算。
4.5 现象:打印任务发出后打印机没反应,程序卡死
原因:OnBeginPrinting里创建的 GDI 对象没有在OnEndPrinting里释放,导致 GDI 句柄耗尽,后续打印任务无法创建 DC。解决:在OnEndPrinting里调用m_printFont.DeleteObject(),并检查所有SelectObject都有对应的恢复操作。
5. 进阶:把打印做成可配置的报表输出
5.1 用 CPrintDialog 自定义打印参数
MFC 默认的打印对话框够用,但如果想控制纸张大小、打印方向、边距,需要自己调CPrintDialog。常见做法是在OnPreparePrinting里先弹自定义对话框,拿到用户选择后设置DEVMODE结构,再传给DoPreparePrinting。
BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { CPrintDialog dlg(FALSE); // FALSE 表示打印对话框 dlg.m_pd.Flags |= PD_PRINTSETUP; // 允许设置纸张和方向 if (dlg.DoModal() != IDOK) { return FALSE; // 用户取消 } // 把用户选择的 DEVMODE 传给打印信息 pInfo->m_pPD->m_pd.hDevMode = dlg.m_pd.hDevMode; pInfo->m_pPD->m_pd.hDevNames = dlg.m_pd.hDevNames; // 设置页数 pInfo->SetMaxPage(CalcPageCount()); return DoPreparePrinting(pInfo); }PD_PRINTSETUP标志让对话框显示纸张和方向选项。hDevMode和hDevNames是 Windows 打印系统的核心结构,分别描述打印机的设备模式和驱动名称。把它们从自定义对话框转移到pInfo->m_pPD后,DoPreparePrinting会用这些参数创建打印机 DC。
5.2 页眉页脚与页码的自动排版
页眉页脚的关键是留出边距。在MM_LOENGLISH下,A4 纸高约 11.69 英寸,对应 1169 个单位。页眉占顶部 100 个单位,页脚占底部 100 个单位,正文区域就是中间 969 个单位。页码放在页脚居中,用GetTextExtent测量字符串宽度,再算居中坐标。
void CMyView::DrawFooter(CDC* pDC, CPrintInfo* pInfo) { CString strPage; strPage.Format(_T("第 %d 页 / 共 %d 页"), pInfo->m_nCurPage, pInfo->GetMaxPage()); // 测量文本宽度,计算居中位置 CSize sz = pDC->GetTextExtent(strPage); int x = (pInfo->m_rectDraw.Width() - sz.cx) / 2; int y = pInfo->m_rectDraw.bottom + 50; // 底部向上 0.5 英寸 pDC->TextOut(x, y, strPage); }GetMaxPage返回总页数,m_nCurPage是当前页。m_rectDraw.bottom在MM_LOENGLISH下是负数,加 50 表示向下偏移 0.5 英寸。居中算法是(可用宽度 - 文本宽度) / 2,这是最稳妥的做法,不依赖字体度量。
5.3 打印数据的验证:先预览再出纸
打印最怕的是浪费纸。我的习惯是每次改完打印代码,先走一遍打印预览,确认三件事:页数对不对、每页内容有没有截断、页眉页脚位置是否正常。预览没问题再实际打印,而且第一张一定用草稿模式。从那以后我每次改打印相关代码,都强制走一遍“预览 → 草稿打印 → 正式打印”的流程,再也没出现过整包纸打废的情况。希望帮到你。
本文还有配套的精品资源,点击获取