简介:这是一份面向C++/MFC开发者的打印功能实现示例,围绕MFC类库对打印作业的封装机制,解决视图文档结构下打印预览、分页输出与打印机设备上下文适配等常见问题,适合具备基础MFC知识、希望为应用快速添加打印支持的中级开发者。资源共50个文件,压缩包22.82MB,包含多个cpp/h源码文件、rc资源描述、工程配置文件以及已编译的exe可执行程序,其中tlog/obj/pdb等构建产物可辅助排查编译与运行细节。已有345人学习交流。内容完整演示了从创建打印机DC、StartDoc/StartPage绘制到EndPage/EndDoc的完整流程,并对多页文档的分页处理、打印选项扩展和错误恢复给出了设计建议;其中对使用CPreviewDC实现打印预览的说明,有助于理解屏幕预览与实际输出之间的差异。工程以MFC_PrintAll为基础,步骤说明与可编译代码相结合,便于直接借鉴改造,可显著缩短自主实现打印功能的时间。
1. 打印功能在 MFC 里到底是什么:先搞懂你面对的是哪套体系
做 MFC 打印功能,很多人打开项目第一反应是找打印按钮,结果发现事情远没那么简单——对话框弹出来了,打印机也转了两下,但输出要么是空白、要么是错位、要么文字糊成一团。实际做过几个带打印的桌面工具后,你会发现 MFC 打印真正难的不是“调出打印对话框”,而是理解它背后的“打印链路”:从视图类的几个回调函数,到打印机的设备上下文(DC),再到分页、页边距、坐标映射,每一环都决定最终纸面上的结果。
本文要解决的就是这一整条链路。适合谁读?正准备给自己的 MFC 项目加打印功能、但还没理清 CView::OnPrint 和 CPrintInfo 怎么配合的开发者;也适合那种打印已经“通了”但多页内容错乱、预览和实际输出不一致、想找出问题根源的熟手。你会发现 MFC 把“打印”和“屏幕绘制”刻意设计在同一套绘制逻辑之下,但又在坐标系统和页面管理上完全不同——搞懂这套设计,功能实现才能真正收尾,而不是弹出个对话框就宣称“打印功能已完成”。
2. MFC 打印的机制与选型:打印对话框能弹出来,不代表打印就通了
2.1 理解 CPrintInfo 与打印流程的四个回调
MFC 的打印功能不是“点按钮就答应”,而是由视图类(CView 派生类)和文档/视图架构协同完成的一套流程。你需要在视图类里重写四个关键函数:OnPreparePrinting、OnBeginPrinting、OnPrint、OnEndPrinting。打印流程实际是这样的:用户触发打印命令(通常是通过 ID_FILE_PRINT 消息),框架会构造一个 CPrintInfo 对象,这个对象承载了当前打印任务的全部状态——页码范围、当前页、打印机 DC 指针、是否取消等。
先解释四个回调的责任:OnPreparePrinting 在打印对话框弹出前调用,用来设置页码范围、设置最小/最大页,甚至可以在这里调用 DoPreparePrinting 让框架执行标准打印设置;OnBeginPrinting 在打印机 DC 已经创建好后调用,适合在这里创建字体、计算分页参数——注意这里拿不到具体某一页的上下文,而是在准备“整份文档”;OnPrint 是实际的每页绘制入口,框架会为每一页调用一次,你在这里做该页的绘制;OnEndPrinting 用来释放 OnBeginPrinting 里创建的 GDI 资源。这个顺序本身就是一条流水线,资源获取和释放必须对应,MFC 在 OnEndPrinting 返回后就会结束打印任务。
这里有个新手常踩的误区:很多人直接在 OnPreparePrinting 里写绘制逻辑。这不是“能跑就行”——OnPreparePrinting 的执行时机是打印机设置阶段,DC 还没有拿到手(甚至可能还没创建),你在这里调用任何需要 DC 的绘制函数都会失败或者产生不可预期结果。我一般会把 OnPreparePrinting 只当作“页数协商”的入口,把真正的资源准备放到 OnBeginPrinting 里。
// 视图类中典型的打印回调骨架 BOOL CMyView::OnPreparePrinting(CPrintInfo* pInfo) { // 设置本打印任务的总页数;如果事先不知道,填一个较大值并在打印中调整 pInfo->SetMaxPage(10); // 调用基类完成标准打印对话框流程 return DoPreparePrinting(pInfo); } void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { // 在这里创建跨页复用的字体或画笔 // pDC 是打印机 DC,可以查询物理纸张和可打印区域 m_printFont.CreatePointFont(90, L"宋体", pDC); } void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 用 pInfo->m_nCurPage 判断当前打印第几页 // 打印页眉、正文内容 } void CMyView::OnEndPrinting(CDC* /*pDC*/, CPrintInfo* /*pInfo*/) { // 回收 GDI 资源 m_printFont.DeleteObject(); }逻辑说明:这个框架的价值在于把“打印一份文档”建模成了“循环调用每一页的绘制函数”,并且把打印机 DC 的生命周期管理好了。你不需要关心任务何时结束、打印作业如何提交给系统,只关心“这一页画什么”。参数上,pInfo->SetMaxPage 用来告诉框架一共有多少页,如果不设置,打印时只出一页;m_nCurPage 从 1 开始递增;DoPreparePrinting 会显示标准打印对话框并调用你重写的另外两个准备函数。
2.2 坐标系统:MM_TEXT 到 MM_TWIPS 的换算与坑点
MFC 打印最容易翻车的不是流程,而是坐标系统。屏幕绘制默认使用 MM_TEXT 映射模式:1 个逻辑单位 = 1 个像素,而打印机的 DC 创建后,默认映射模式其实也是 MM_TEXT,但这个“像素”的物理尺寸取决于打印机当前的分辨率——同样是 100 个逻辑单位,在 300 DPI 的打印机上就是 1/3 英寸,在 600 DPI 上就是 1/6 英寸。所以直接拿屏幕绘制代码去打印,输出尺寸会随打印机分辨率变化,这就是“打印出来字小得看不见”或“内容偏移到纸外”的根源。
常见做法是显式把打印机 DC 的映射模式改成 MM_TWIPS。这是 MFC 打印的经典方案:1 TWIP = 1/20 点 = 1/1440 英寸,是相对固定的物理单位。把映射模式设为 MM_TWIPS 后,坐标的 Y 轴方向也反转了——MM_TEXT 是 Y 轴向下,MM_TWIPS 是 Y 轴向上,原点在页面左上角。这意味着你的绘制逻辑里,y 值越大越靠下(MM_TEXT 习惯)的逻辑要整个反转。
void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { // 关键是设置打印 DC 的映射模式 // MM_TWIPS:1 逻辑单位 = 1/1440 英寸,与屏幕分辨率无关 pDC->SetMapMode(MM_TWIPS); // 同时设置视口原点,留出页边距:左边 500 TWIPS,上边 500 TWIPS // 注意 SetViewportOrg 的参数是设备坐标,此时 1 单位 = 1/1440 英寸 pDC->SetViewportOrg(500, 500); // 查询可打印区域,用于计算有效宽度 int nHorzRes = pDC->GetDeviceCaps(HORZRES); // 物理宽度,单位是像素 int nVertRes = pDC->GetDeviceCaps(VERTRES); // 物理高度,单位是像素 int nLogPixelsX = pDC->GetDeviceCaps(LOGPIXELSX); // 水平分辨率 DPI int nLogPixelsY = pDC->GetDeviceCaps(LOGPIXELSY); // 垂直分辨率 DPI }参数说明:SetViewportOrg(500, 500) 里的单位已经被映射模式影响,500 是 500/1440 英寸,约 0.35 英寸。这个值就是页边距,你可以按实际需求调整——如果打印机不支持无边距打印,这个值不能小于硬件的最小页边距,否则内容会被打印机裁剪掉。GetDeviceCaps 查出来的结果需要注意:HORZRES 返回的是设备像素值,要换算成逻辑单位时需要乘以 1440 再除以 LOGPIXELSX。很多代码里直接把 HORZRES 当 TWIP 用,结果宽度计算差了整整一个 DPI 倍率,多页分页位置就全乱了。
2.3 选型问题:文档/视图架构还是非文档/视图架构
如果你是在一个对话框程序(Dialog Based)或非文档/视图架构的工程里做打印,上面这套 CView 回调根本不适用。你需要自己管理 CDC::StartDoc / StartPage / EndPage / EndDoc 这四个 API。这是另一条打印路线:直接创建打印机 DC、提交打印作业、逐页输出。
我做过几次这种改造,说下区别:文档/视图架构里 MFC 已经帮你处理了打印预览(CPrintPreviewView)、取消作业、打印机设置对话框,你只需要实现回调;非文档/视图架构则更接近裸 Win32 打印,灵活但也更容易出错。如果你的工程已经用了 CView,完全没必要绕开;如果本身就是对话框程序、只是临时要加打印,直接调用 StartDoc 路线反而更直接。
// 非文档/视图架构下的最小打印流程 void PrintDirect(CDC* pDC, const CString& strText) { DOCINFO di = { sizeof(DOCINFO), L"打印任务名", NULL }; pDC->StartDoc(&di); // 提交一个打印作业 pDC->StartPage(); // 开始第 1 页 pDC->SetMapMode(MM_TWIPS); pDC->SetViewportOrg(500, 500); // TextOut 的 y 坐标为负值,因为 TWIPS 的 Y 轴向上 pDC->TextOut(0, 0, strText); pDC->EndPage(); // 结束第 1 页 pDC->EndDoc(); // 完成打印作业 }这段代码的核心是手动管理页生命周期。StartDoc 开始一个作业,StartPage 和 EndPage 成对出现,每对之间是一页的绘制。EndDoc 提交作业到打印队列。注意 TextOut 的第二个参数我写的是 0,但在 MM_TWIPS 下,如果想要内容显示在页边距下方,y 应该是负值——因为视口原点被 SetViewportOrg 移过之后,坐标轴是 Y 向上。这块写错过几次,每次都是打印出来第一行内容跑到页面外面。
选型结论:如果你的项目本身就是 MFC 的 SDI/MDI 文档视图程序,用 CView 回调路线是正统做法,打印预览功能免费获得;如果是对话框程序,且只是打印简单报表,直接 StartDoc 路线代码量小、依赖少,但打印预览需要自己实现,或者直接不做。
3. 在当前环境里落地“最小可用打印”:从零跑通单页输出
3.1 改造前的资源和环境准备
开始动手前,先确认你的工程类型和资源。打开你的 MFC 工程,在资源视图里检查菜单或工具栏是否已经有“打印”相关的命令(ID 通常是 ID_FILE_PRINT)。如果是文档/视图架构,CView 的默认消息路由已经帮你把打印命令接上了——你不需要在消息映射里手动加 ON_COMMAND(ID_FILE_PRINT, OnFilePrint),MFC 框架在 CView 的基类中已经处理了这个命令。但如果你没有重写任何回调,点了打印只会弹出打印对话框,确定后输出一张空白纸,因为默认的 OnPrint 什么都不画。
环境上,准备一个可用的 Windows 系统和一个支持“打印到 PDF”的虚拟打印机——现在 Windows 10/11 自带 Microsoft Print to PDF,这是调试打印功能最好的工具,不需要真的接一台物理打印机。先确保这个虚拟打印机可用,后面排查问题时会反复用到。
工程配置方面,检查项目是否链接了 MFC 库,以及是否定义了 UNICODE。如果你的工程是 ANSI 编码,下面代码里的宽字符串需要改成 _T() 宏包裹,否则编译报错。我一般直接用 _T() 包裹所有字符串字面量,保证 ANSI/UNICODE 都能编译。
3.2 重写 OnPrint 实现真正的页面内容输出
单页打印是最简单的落地场景。假设你要打印的内容是一段文本和几条简单的线条,直接重写 OnPrint 即可。首先确认你的视图类已经重写了 OnPreparePrinting——即使只打一页,也要调用 DoPreparePrinting 来弹出打印对话框。然后重写 OnPrint,在这里完成全部绘制。
void CMyView::OnPrint(CDC* pDC, CPrintInfo* { // 设置映射模式,保证物理尺寸一致 pDC->SetMapMode(MM_TWIPS); // 页边距:左 1 厘米,上 1 厘米(1 厘米 ≈ 567 TWIPS) int nMarginX = 567; int nMarginY = 567; pDC->SetViewportOrg(nMarginX, nMarginY); // 获取可打印区域的逻辑尺寸 int nPageWidth = pDC->GetDeviceCaps(HORZRES); int nPageHeight = pDC->GetDeviceCaps(VERTRES); int nLogPixelsX = pDC->GetDeviceCaps(LOGPIXELSX); int nLogPixelsY = pDC->GetDeviceCaps(LOGPIXELSY); // 转换成 TWIPS 逻辑单位:物理像素 × 1440 / DPI int nWidthTwips = MulDiv(nPageWidth, 1440, nLogPixelsX); int nHeightTwips = MulDiv(nPageHeight, 1440, nLogPixelsY); // 在页边距内画一个矩形边框,验证坐标系统 CRect rcPage(0, -nHeightTwips, nWidthTwips, 0); pDC->Rectangle(&rcPage); // 输出一行文本,注意 MM_TWIPS 下 y 为负值表示向下偏移 CFont font; font.CreatePointFont(120, _T("宋体"), pDC); CFont* pOldFont = pDC->SelectObject(&font); pDC->TextOut(200, -200, _T("MFC 打印测试 - 第 1 页")); // 画一条横线,验证线条输出 pDC->MoveTo(0, -400); pDC->LineTo(nWidthTwips, -400); pDC->SelectObject(pOldFont); font.DeleteObject(); }注意 OnPrint 的实现里没有出现页码判断——单页打印不用管 m_nCurPage。但你要留意一点:这页内容只在第 1 页出现,如果打印对话框里用户输入了“打印到第 2 页”,第 2 页会是空白。要正确处理这种情况,需要在绘制前判断 pInfo->m_nCurPage 是否为 1,不是就直接返回。这种“逻辑上只管第 1 页”的处理方式在单页模板里是合理的,但如果文档可能扩展为多页,最好从一开始就按页码来组织绘制。
3.3 在非文档/视图工程里走通同一个目标
如果你的工程没有 CView,按 2.3 里的方式,在按钮响应函数里直接写完整的打印流程。这里的难点在于创建打印机 DC——需要调用 CreateDC 和打印机驱动名。Windows 提供了枚举打印机的方式,但为了快速跑通,可以先弹出一个打印对话框让用户选择打印机,然后用所选打印机的 DC 创建打印上下文。
void CMyDialog::OnBnClickedPrint() { CPrintDialog dlg(FALSE); // FALSE 表示打印对话框,TRUE 是打印设置对话框 if (dlg.DoModal() != IDOK) return; // 从打印对话框拿到打印机 DC HDC hDC = dlg.GetPrinterDC(); CDC dc; dc.Attach(hDC); // 检查并设置打印方向(横向/纵向) DEVMODE* pDevMode = dlg.GetDevMode(); if (pDevMode->dmOrientation == DMORIENT_LANDSCAPE) { // 横向打印:需要调整可打印区域的宽高计算 } // 走上一节的标准流程 DOCINFO di = { sizeof(DOCINFO), _T("对话框打印任务"), NULL }; dc.StartDoc(&di); dc.StartPage(); dc.SetMapMode(MM_TWIPS); dc.SetViewportOrg(567, 567); dc.TextOut(0, 0, _T("对话框程序直接打印")); dc.EndPage(); dc.EndDoc(); dc.Detach(); // 由框架释放 HDC DeleteDC(hDC); }把这一节读完,你会发现单页打印的代码量其实不大,核心就两步:拿到一个打印机 DC、在这个 DC 上做和屏幕绘制类似的 GDI 输出。真正的复杂度在后头——多页、分页、以及预览。那是下一章的活。
4. 多页分页与打印预览:让内容一页页地出,并保证预览不翻车
4.1 多页分页:根据内容量计算页数并逐页输出
数据量超过一页时,你需要让 OnPreparePrinting 返回正确总页数,并在 OnPrint 里按当前页号绘制对应部分。分页的核心问题从“怎么画”变成“哪里该断开”。常见做法有两种:固定高度分页和内容量估算分页。固定高度分页适合表格或清单类报表,你预先知道每行占多少高度;内容量估算适合富文本,需要测量文本实际绘制高度后累计分页。
先看固定高度分页。假设一页可打印内容高度为 H,每条记录高度固定为 hRow,那么一页能放 nRowPerPage = H / hRow 条。OnPreparePrinting 里把总页数设为 ceil(总记录数 / nRowPerPage)。OnPrint 里根据 m_nCurPage 算出本页要画哪几条记录:
void CMyView::OnPreparePrinting(CPrintInfo* pInfo) { // 计算总页数 int nRowPerPage = 20; // 每页 20 行 int nTotalRows = m_dataList.GetCount(); // 假设数据源是列表 int nTotalPages = (nTotalRows + nRowPerPage - 1) / nRowPerPage; pInfo->SetMaxPage(nTotalPages); return DoPreparePrinting(pInfo); } void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { pDC->SetMapMode(MM_TWIPS); pDC->SetViewportOrg(567, 567); int nRowPerPage = 20; int nStartRow = (pInfo->m_nCurPage - 1) * nRowPerPage; int nEndRow = min(nStartRow + nRowPerPage, m_dataList.GetCount()); int y = 0; for (int i = nStartRow; i < nEndRow; i++) { // y 每次下移一行高度,TWIPS 下 y 需要取负数 pDC->TextOut(0, y, m_dataList.GetAt(i)); y -= 300; // 每行 300 TWIPS ≈ 0.53 厘米 } }这种分页逻辑的关键在 m_nCurPage 和行号的映射。看起来直白,但有一个隐藏风险:打印过程中如果用户选择了“从第 2 页开始打印”,OnPrint 的第 1 页参数就是 2,此时 nStartRow 也能正确跳到对应数据——这个映射必须是纯函数,不能依赖任何“上一页的状态”。很多人在这里用了一个成员变量存“当前已打印行数”,结果一旦用户跳页打印,数据就错位了。正确做法是每次从页码反推出行的位置,不做状态累积。
4.2 与打印预览协同:为什么预览和实际打印不一致
MFC 的打印预览本质上是用屏幕 DC 模拟打印机 DC 输出。CPrintPreviewView 接管了打印流程,它创建了一个模拟 DC,把打印机 DC 做成内存位图,照样走 OnPaint 或 OnPrint 绘制。问题是模拟 DC 和真实打印机 DC 的分辨率、可打印区域有差异——预览时是屏幕 DPI(通常 96 或 120),打印时是打印机 DPI(300 或 600)。如果你的绘制代码使用 MM_TEXT 且没有做任何分辨率换算,预览 100 像素的内容在打印时会缩小或放大到完全不同的物理尺寸。
保证预览一致的第一原则:绘制代码里不依赖任何像素值。所有尺寸都用 TWIPS 或毫米等物理单位,或者用 CreatePointFont 按点阵创建字体,而不是按像素高度创建字体。第二原则:不要让预览走独立的绘制分支。很多人为了让预览“好看”,单独写了 OnDraw 预览版本,结果预览效果和实际输出大相径庭。MFC 的标准做法是打印和屏幕绘制共用一套绘制函数,只是 DC 的映射模式不同。我自己习惯在 OnPrint 里调用一个统一的绘制方法 DrawReport(CDC* pDC, CRect rcPage),OnDraw 和 OnPrint 都调用它,只是传入的 rcPage 不同,这样预览和打印永远走同一份逻辑,不一致率大幅下降。
void CMyView::OnDraw(CDC* pDC) { // 屏幕绘制:用窗口客户区作为绘制区域 CRect rcClient; GetClientRect(&rcClient); DrawReport(pDC, rcClient); } void CMyView::OnPrint(CDC* pDC, CPrintInfo* { // 打印绘制:用可打印区域作为绘制区域(按物理单位) pDC->SetMapMode(MM_TWIPS); int nWidthTwips = MulDiv(pDC->GetDeviceCaps(HORZRES), 1440, pDC->GetDeviceCaps(LOGPIXELSX)); int nHeightTwips = MulDiv(pDC->GetDeviceCaps(VERTRES), 1440, pDC->GetDeviceCaps(LOGPIXELSY)); CRect rcLogic(0, -nHeightTwips, nWidthTwips, 0); pDC->SetViewportOrg(567, 567); DrawReport(pDC, rcLogic); } void CMyView::DrawReport(CDC* pDC, const CRect& rcPage) { // 统一的绘制逻辑:只使用 rcPage 作为边界,不关心 DC 的类型 pDC->Rectangle(rcPage); // ... 具体内容绘制 }预览和打印还有一个常见差异源:打印机的“不可打印区域”。物理纸张的边缘,打印头是画不上的(除非打印机支持无边距)。GetDeviceCaps 返回的 HORZRES / VERTRES 是“可打印区域”的尺寸,而不是纸张尺寸。你用 SetViewportOrg(567, 567) 设置的边距如果小于打印机硬件默认边距,内容会被裁掉一部分。在屏幕上预览看不到这个问题,因为屏幕 DC 没有硬件边距限制。这是“预览正常,打印缺边”的最常见原因。解法是主动查询打印机 DC 的 PHYSICALOFFSETX / PHYSICALOFFSETY,把边距设置为不小于硬件偏移的值。
4.3 实现“打印预览”与缩放
如果你的程序是非文档/视图架构,想要预览功能就得自己实现一套。最省事的方式是把打印内容绘制到内存位图,再在屏幕上按比例缩放显示。这里的关键是缩放比例的处理——内存位图尺寸按打印机 DC 的像素尺寸生成,但屏幕显示区域可能很小,需要缩小显示;此时的文字是否清晰取决于你是用 GDI 的 StretchBlt 直接缩放(会糊)还是重新按屏幕 DC 绘制(清晰但两套代码)。
我这里建议一种折中方案:预览时用屏幕 DC 创建一个比例相当的内存 DC 和位图,按打印机 DC 同样的逻辑单位(MM_TWIPS),但将视口原点偏移比例缩小。也就是说预览时画的内容和打印时完全一致——都是 TWIPS 逻辑单位——只是预览 DC 的物理像素密度低于打印机 DC。这样预览代码和打印代码走同一条路径,只是 DC 的物理分辨率不同,视觉上预览按“纸张缩略图”显示。
MFC 的文档/视图架构里预览是免费的,不需要关心这些细节。所以如果预览是你的硬需求,我建议一开始就别选对话框架构,直接用 SDI 文档视图架构来做打印。这是从选型层面规避复杂度,比后期补预览功能成本低得多。
5. 打印功能避坑手册:最容易翻车的四个真实场景还原
5.1 现象:打印出来全是空白页,但打印对话框一切正常
原因:OnPreparePrinting 里没有调用 DoPreparePrinting,或者 OnPrint 里没有做任何实际绘制。前者会导致 MFC 没有正确初始化打印流程,后者说明你的打印回调根本没有被调用或者绘制逻辑被 map mode 设置错误给吞掉了。
解决:先在 OnPrint 第一行加一个 AfxMessageBox 作为断点,确认回调确实执行了。如果 MessageBox 弹出来了但输出还是空白,检查你是否把映射模式设成了 MM_TEXT 且坐标超出了页面范围——比如 y 用了正 500,在 MM_TEXT 下这行文本会显示在页面最上方,但如果同时设置了 MM_TWIPS 且 y 用了正 500,内容就跑到了页面的“上边界之外”,因为 TWIPS 的 Y 轴向上。空白页八成是坐标方向性错误,把 y 改负号再看一次。
5.2 现象:打印预览正常,但物理打印机输出内容向右下偏移或边缘被裁切
原因:打印机的硬件边距(不可打印区域)没有被计算。屏幕 DC 没有这个约束,打印机从纸张物理边缘往内的一定宽度根本无法打印。你把 SetViewportOrg 设成很小的边距值(比如 100 TWIPS,约 0.18 厘米),但打印机硬件最小边距是 0.25 厘米,结果内容从 0.18 厘米处开始排,但打印机实际从 0.25 厘米处才开始出墨,相当于整体内容向右下方移了 0.07 厘米。如果一页内容恰好贴边绘制,边缘那段就被切了。
解决:用 GetDeviceCaps(PHYSICALOFFSETX) 和 GetDeviceCaps(PHYSICALOFFSETY) 查询硬件偏移,然后把你设置的页边距加上这个偏移。从打印机 DC 角度,ViewportOrg 应该设置为“硬件偏移 + 期望边距”的总值。注意这个值在 MM_TWIPS 下需要做 DPI 换算:PHYSICALOFFSETX 单位是设备像素,要乘以 1440 再除以 LOGPIXELSX 才是 TWIPS 值。
// 正确处理打印机硬件边距 int nOffX = pDC->GetDeviceCaps(PHYSICALOFFSETX); int nOffY = pDC->GetDeviceCaps(PHYSICALOFFSETY); int nLogPixelsX = pDC->GetDeviceCaps(LOGPIXELSX); int nLogPixelsY = pDC->GetDeviceCaps(LOGPIXELSY); // 期望页边距:1 厘米 ≈ 567 TWIPS int nMarginTwips = 567; // 实际视口原点 = 硬件偏移(换算成 TWIPS)+ 期望边距 int nOrgX = MulDiv(nOffX, 1440, nLogPixelsX) + nMarginTwips; int nOrgY = MulDiv(nOffY, 1440, nLogPixelsY) + nMarginTwips; pDC->SetViewportOrg(nOrgX, nOrgY);5.3 现象:多页打印时,只有第一页有内容,后续页全是空白
原因:OnPrint 里没有根据 m_nCurPage 对不同页输出不同内容。很多人把绘制逻辑写在 OnBeginPrinting 里——那里只会执行一次,不是每页执行。或者 OnPrint 里判断了页码,但判断条件写错,比如 m_nCurPage == 1 时输出内容,其他页直接 return 了。
解决:先确认 OnPreparePrinting 里调用了 SetMaxPage 且传入了大于 1 的值——如果没设置,MFC 认为只打一页,框架根本不会为第 2 页调用 OnPrint。然后检查 OnPrint 里是否使用了 m_nCurPage 来区分页内容。建议用“起始行号 + 每页行数”的方式来分配各页数据,而不是用 switch 逐页列举——后者在数据量增长了以后代码会膨胀到不可维护。最后,用“打印到 PDF”功能实际输出一份多页文档,查看每一页的内容,而不是只看预览。
5.4 现象:打印文字模糊、有锯齿,或字体和屏幕显示完全不一样
原因:创建字体时用了像素单位,而屏幕和打印机的 DPI 不同导致字体渲染大小不一致。或者忘记在 OnBeginPrinting 里创建字体,而在 OnPrint 里每页都创建+删除,频繁的 GDI 对象操作产生了性能问题和显示异常。
解决:始终用 CreatePointFont 或 CreateFont 的 nHeight 参数传负的逻辑单位并按 TWIPS 换算。CreatePointFont(120, _T("宋体"), pDC) 表示 12 磅大小,在屏幕和打印机上物理尺寸一致。字体创建时机放在 OnBeginPrinting 里,OnPrint 只负责 SelectObject,OnEndPrinting 里 DeleteObject。如果发现字体在打印时丢失了某些字形(比如中文显示成方框),检查打印机字体替换设置——某些打印机驱动会替换字体,这是硬件行为,无法在代码层避免,只能尽量选择常见字体。
6. 打印到 PDF 做自检:验证打印结果正确性的高效手段
打完上面这些坑,最有价值的习惯就是“把 Microsoft Print to PDF 当成标配测试工具”。每次改动打印逻辑后,先在打印对话框里选 PDF 打印机输出一份文件,然后打开 PDF 检查每页内容。PDF 输出的坐标、分页、字体都和物理打印机走同一套 GDI 打印链路,只是最终的渲染目标是文件而不是纸张。可以理解为:打印到 PDF 相当于一个零成本、可反复查看的“物理打印机模拟器”,你不需要浪费纸张就能验证页边距、分页、内容顺序是否都对。
验证时重点看三个地方:同一行的文字是否对齐,多页之间的页眉/页脚是否在每页同一位置,以及表格线是否连续——这些问题在 PDF 文件里放大去看比看打印纸清楚得多。尤其是分页后表头是否重复、页脚是否出现在每一页最下方,这类格式问题在翻看 PDF 时一眼就能定位翻车位置。
另外一个实用技巧:把页码作为打印内容的一部分输出到每页页脚。打印完 PDF 后按顺序翻页,如果发现页码断档,说明 OnPrint 的页码映射逻辑有漏页;如果页码重复,说明数据分配和页码计算不一致。这两个问题靠肉眼检查打印纸非常痛苦,但靠 PDF 翻页几秒钟就能判断。我现在的习惯是:任何打印功能的修改都必须生成一份完整的 PDF 测试件,确认没有问题后再交给对方接物理打印机实测。打印到 PDF 这一步,基本能提前挡住八成的打印翻车事故。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取