戰(zhàn):從OnPrint分頁到打印預(yù)覽避坑指南)
簡介這份資源面向具備一定C與Windows開發(fā)基礎(chǔ)的MFC學(xué)習(xí)者聚焦在MFC應(yīng)用中實(shí)現(xiàn)打印與打印預(yù)覽這一常見但細(xì)節(jié)較多的功能點(diǎn)。內(nèi)容圍繞CView派生類的打印機(jī)制展開涵蓋打印機(jī)設(shè)備上下文創(chuàng)建、StartDoc/StartPage/EndDoc打印作業(yè)流程、OnDraw適配打印機(jī)DC、CPrintDialog打印對話框、CPreviewDC打印預(yù)覽以及分頁處理、打印選項(xiàng)擴(kuò)展、錯誤恢復(fù)與性能優(yōu)化等實(shí)踐要點(diǎn)幫助讀者理解從屏幕繪制到打印輸出的完整鏈路。資源包共50個文件以h頭文件、cpp源文件、rc資源腳本、vcxproj工程文件為主另含exe可執(zhí)行程序、pdb調(diào)試符號、manifest清單及tlog編譯日志等壓縮包約22.82MB工程結(jié)構(gòu)完整可直接編譯運(yùn)行。目前已有346人學(xué)習(xí)下載適合需要為MFC項(xiàng)目補(bǔ)充打印模塊或?qū)φ帐纠挪榇蛴栴}的開發(fā)者參考。1. 從一張報(bào)表說起MFC打印功能到底能解決什么上周幫朋友看一個老項(xiàng)目需求很樸素把界面上那個 CListCtrl 里的數(shù)據(jù)打出來。他折騰了一下午要么打印出來是空白要么直接彈異常。問題不在數(shù)據(jù)在于 MFC 的打印鏈路和屏幕繪制完全是兩套邏輯——屏幕上有 DC 就能畫打印機(jī)得先拿到打印 DC、再走文檔/視圖的 OnPrint 流程。這份資源就是圍繞這個場景來的在 MFC 單文檔或多文檔程序里把打印、打印預(yù)覽、頁面設(shè)置這一整套跑通。適合誰手頭維護(hù)著 MFC 老系統(tǒng)、需要補(bǔ)打印模塊的開發(fā)者或者剛接觸 GDI 繪圖、想搞明白CDC和OnDraw/OnPrint分工的人。它不依賴第三方庫純 Win32 MFC 自帶機(jī)制編譯出來就能用。2. 打印鏈路拆解從菜單命令到紙面輸出的完整路徑MFC 的打印不是調(diào)一個Print()就完事它是一套框架級的消息流轉(zhuǎn)。理解這條鏈路后面改代碼才不會瞎猜。2.1 框架默認(rèn)幫你做了什么在單文檔程序里CView派生類天生就帶打印能力。你打開資源編輯器ID 為ID_FILE_PRINT、ID_FILE_PRINT_PREVIEW、ID_FILE_PRINT_SETUP這幾個菜單項(xiàng)MFC 的CView::OnFilePrint已經(jīng)接好了。整個流程大致是用戶點(diǎn)“打印”觸發(fā)ID_FILE_PRINT命令。CView::OnFilePrint被調(diào)用它內(nèi)部創(chuàng)建CPrintInfo結(jié)構(gòu)彈出打印對話框??蚣苷{(diào)用OnBeginPrinting給你機(jī)會創(chuàng)建打印專用的 GDI 對象比如字體、畫筆。對每一頁框架調(diào)用OnPrepareDC調(diào)整 DC 的視口原點(diǎn)然后調(diào)用OnPrint。OnPrint默認(rèn)會轉(zhuǎn)調(diào)OnDraw這就是為什么很多人只寫OnDraw也能打印——但只能打當(dāng)前屏幕那一頁。全部頁打完調(diào)用OnEndPrinting釋放資源。關(guān)鍵點(diǎn)OnDraw收到的 DC 在打印時是打印機(jī) DC不是屏幕 DC。如果你在OnDraw里寫了GetClientRect拿客戶區(qū)大小打印時這個值會變成打印機(jī)可打印區(qū)域邏輯就亂了。2.2 分頁的核心OnPrepareDC 與 CPrintInfo要打多頁必須自己控制分頁。MFC 用CPrintInfo的m_nCurPage和m_nNumPreviewPages來跟蹤。每頁打印前框架會調(diào)用OnPrepareDC你需要在這里根據(jù)當(dāng)前頁碼設(shè)置映射模式。void CMyView::OnPrepareDC(CDC* pDC, CPrintInfo* pInfo) { CView::OnPrepareDC(pDC, pInfo); if (pInfo ! nullptr) // 打印或預(yù)覽時才處理 { // 設(shè)置映射模式為 MM_TEXT1 邏輯單位 1 像素 pDC-SetMapMode(MM_TEXT); // 根據(jù)當(dāng)前頁碼把原點(diǎn)往上移實(shí)現(xiàn)“翻頁” // 每頁高度按可打印區(qū)域算 int nPageHeight pDC-GetDeviceCaps(VERTRES); pDC-SetViewportOrg(0, -(pInfo-m_nCurPage - 1) * nPageHeight); } }邏輯說明SetViewportOrg把邏輯坐標(biāo)原點(diǎn)沿 Y 軸負(fù)方向偏移第 1 頁偏移 0第 2 頁偏移一個頁高這樣OnDraw里畫的內(nèi)容就像一條長卷每頁露出不同的一段。參數(shù)VERTRES是打印機(jī)垂直方向的可打印像素?cái)?shù)不同打印機(jī)不一樣所以不能寫死。2.3 OnPrint 里該寫什么OnPrint是真正決定“這一頁畫什么”的地方。默認(rèn)實(shí)現(xiàn)是調(diào)OnDraw但多頁打印時你得自己接管。void CMyView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { // 先畫頁眉 CFont fontHeader; fontHeader.CreatePointFont(100, _T(宋體)); CFont* pOldFont pDC-SelectObject(fontHeader); pDC-TextOut(0, 0, _T(數(shù)據(jù)報(bào)表)); // 畫表格線 int nStartY 200; // 給頁眉留出空間 int nRowHeight 300; for (int i 0; i m_arrData.GetSize(); i) { int y nStartY i * nRowHeight; pDC-Rectangle(0, y, 8000, y nRowHeight); pDC-TextOut(100, y 50, m_arrData[i]); } pDC-SelectObject(pOldFont); }邏輯說明這里沒有調(diào)CView::OnPrint因?yàn)槲覀円耆刂评L制內(nèi)容。m_arrData是視圖類里存數(shù)據(jù)的CStringArray實(shí)際項(xiàng)目里換成你的數(shù)據(jù)源即可。注意Rectangle的坐標(biāo)用的是邏輯單位在MM_TEXT下就是像素但打印機(jī)分辨率通常比屏幕高得多所以數(shù)值要放大——8000 這個寬度在 600 DPI 打印機(jī)上大約對應(yīng) 1.3 英寸具體得按你的紙張調(diào)。2.4 打印預(yù)覽為什么容易翻車打印預(yù)覽走的是同一套OnPrint但 DC 是屏幕 DC尺寸按預(yù)覽窗口縮放。很多人預(yù)覽正常、真打印就錯位原因是OnPrepareDC里用了GetDeviceCaps拿打印機(jī)參數(shù)預(yù)覽時拿到的是屏幕參數(shù)。穩(wěn)妥做法是在OnBeginPrinting里根據(jù)pInfo-m_pPD-GetDeviceName()判斷或者干脆用pInfo-m_rectDraw這個框架算好的可打印矩形。void CMyView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo) { // 用框架算好的可打印區(qū)域避免自己算錯 m_rectPrint pInfo-m_rectDraw; // 創(chuàng)建打印專用字體只創(chuàng)建一次所有頁復(fù)用 m_pPrintFont new CFont; m_pPrintFont-CreatePointFont(100, _T(宋體)); }m_rectDraw是CRect類型單位是邏輯單位已經(jīng)考慮了打印機(jī)邊距。用它來布局預(yù)覽和實(shí)際打印才能一致。3. 動手跑起來從空項(xiàng)目到能出紙的最小閉環(huán)這一章按步驟走目標(biāo)是讓你在半小時內(nèi)看到一個能打印的窗口。3.1 創(chuàng)建項(xiàng)目與菜單資源用 Visual Studio 新建一個 MFC 單文檔項(xiàng)目項(xiàng)目名隨意。在資源視圖里打開IDR_MAINFRAME菜單確認(rèn)“文件”菜單下有“打印”“打印預(yù)覽”“頁面設(shè)置”三項(xiàng)ID 分別是ID_FILE_PRINT、ID_FILE_PRINT_PREVIEW、ID_FILE_PRINT_SETUP。如果沒有手動加上ID 必須用這三個否則框架的消息映射接不上。3.2 在視圖類里加數(shù)據(jù)成員打開你的View.h在類聲明里加protected: CStringArray m_arrData; // 模擬要打印的數(shù)據(jù) CRect m_rectPrint; // 可打印區(qū)域 CFont* m_pPrintFont; // 打印字體然后在構(gòu)造函數(shù)里初始化m_pPrintFont nullptr;在析構(gòu)里delete m_pPrintFont;。數(shù)據(jù)可以在OnInitialUpdate里塞幾條進(jìn)去void CMyView::OnInitialUpdate() { CView::OnInitialUpdate(); m_arrData.Add(_T(第一行數(shù)據(jù))); m_arrData.Add(_T(第二行數(shù)據(jù))); m_arrData.Add(_T(第三行數(shù)據(jù))); }3.3 重寫三個關(guān)鍵虛函數(shù)在類向?qū)Ю锾砑覱nBeginPrinting、OnPrepareDC、OnPrint三個重寫。代碼按第 2 章的示例填。注意OnPrepareDC里要調(diào)基類OnPrint里不調(diào)基類因?yàn)槲覀円约寒嫛?.4 編譯運(yùn)行與驗(yàn)證按 F5 運(yùn)行點(diǎn)“打印預(yù)覽”。如果預(yù)覽窗口里能看到三行數(shù)據(jù)說明鏈路通了。再點(diǎn)“打印”選一個虛擬打印機(jī)比如系統(tǒng)自帶的“Microsoft Print to PDF”輸出成 PDF 檢查。常見失敗預(yù)覽空白——檢查OnPrint是否被調(diào)用可以在里面加TRACE輸出打印錯位——檢查OnPrepareDC里SetViewportOrg的偏移量是否用了VERTRES。提示調(diào)試打印時用“Microsoft Print to PDF”比真打印機(jī)方便輸出文件可以直接打開看效果不用浪費(fèi)紙。4. 避坑與排查打印模塊最常見的五個翻車現(xiàn)場4.1 預(yù)覽正常打印出來只有第一頁現(xiàn)象預(yù)覽能翻頁真打印只出一頁。 原因OnPrepareDC里沒有根據(jù)m_nCurPage調(diào)整原點(diǎn)所有頁畫在了同一位置。 解決確認(rèn)SetViewportOrg的偏移量計(jì)算里用了pInfo-m_nCurPage并且OnPrint里沒有提前return。4.2 打印內(nèi)容被截?cái)嗷蚩s在角落現(xiàn)象紙上只有左上角一小塊有內(nèi)容。 原因映射模式?jīng)]設(shè)對或者用了屏幕坐標(biāo)硬編碼。打印機(jī) DPI 通常是 600 以上屏幕才 96同樣的數(shù)值在紙上會小很多。 解決統(tǒng)一用MM_TEXT并在OnBeginPrinting里根據(jù)m_rectDraw動態(tài)計(jì)算布局不要寫死像素值。4.3 打印對話框彈不出來現(xiàn)象點(diǎn)“打印”沒反應(yīng)。 原因菜單 ID 不是ID_FILE_PRINT或者視圖類沒有正確繼承CView的打印消息映射。 解決檢查BEGIN_MESSAGE_MAP里是否有ON_COMMAND(ID_FILE_PRINT, CView::OnFilePrint)沒有就手動加。4.4 內(nèi)存泄漏與 GDI 對象耗盡現(xiàn)象連續(xù)打印多次后程序變慢或崩潰。 原因OnBeginPrinting里new的字體沒在OnEndPrinting里delete或者SelectObject后沒恢復(fù)舊對象。 解決成對寫OnBeginPrinting/OnEndPrintingSelectObject之后務(wù)必SelectObject(pOld)恢復(fù)。4.5 中文亂碼或字體不對現(xiàn)象打印出來中文變成問號。 原因CreatePointFont里字體名用了_T(宋體)但系統(tǒng)沒有或者項(xiàng)目字符集不匹配。 解決確認(rèn)項(xiàng)目屬性里字符集是“使用 Unicode 字符集”字體名用_T(SimSun)或_T(宋體)都行但要在目標(biāo)機(jī)器上存在。5. 二次開發(fā)改造把打印模塊抽成可復(fù)用組件跑通最小閉環(huán)之后真正有價(jià)值的是把這套東西抽出來讓多個視圖共用。我一般會做一個CPrintHelper類把分頁計(jì)算、頁眉頁腳、表格繪制這些邏輯收進(jìn)去。5.1 設(shè)計(jì)一個打印輔助類class CPrintHelper { public: CPrintHelper(); ~CPrintHelper(); // 設(shè)置數(shù)據(jù)源用回調(diào)方式讓調(diào)用者決定怎么畫 void SetDataProvider(std::functionvoid(CDC*, const CRect, int) painter); // 在 OnPrint 里調(diào)這個 void DrawPage(CDC* pDC, CPrintInfo* pInfo, const CRect rectDraw); // 在 OnPrepareDC 里調(diào)這個 void PrepareDC(CDC* pDC, CPrintInfo* pInfo); private: std::functionvoid(CDC*, const CRect, int) m_painter; int m_nPageHeight; };SetDataProvider接收一個 lambda調(diào)用者只需要關(guān)心“第 n 頁在 rectDraw 里畫什么”分頁偏移和 DC 準(zhǔn)備由 helper 統(tǒng)一處理。這樣換一個視圖只要重新傳一個 lambda 就行。5.2 驗(yàn)證改造效果改造完之后用兩個不同的視圖分別調(diào)CPrintHelper一個打表格一個打文本。如果兩者都能正常預(yù)覽和打印說明抽象是成功的。驗(yàn)證時重點(diǎn)看頁碼是否正確遞增、最后一頁是否有多余空白頁、預(yù)覽縮放比例是否一致。5.3 一個容易忽略的細(xì)節(jié)OnPrint里拿到的pInfo-m_rectDraw在預(yù)覽和打印時單位可能不同。穩(wěn)妥做法是在OnBeginPrinting里把它存下來并且用pDC-DPtoLP轉(zhuǎn)成邏輯單位再用。我吃過這個虧預(yù)覽時看著剛好打印出來右邊少一截查了半天才發(fā)現(xiàn)是單位沒統(tǒng)一。從那以后我每次寫打印模塊都強(qiáng)制在OnBeginPrinting里把m_rectDraw轉(zhuǎn)成邏輯單位存成成員變量所有繪制都用這個變量不再直接碰pInfo。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取