MFC映射
所有CDC输出函数最终都会输出到物理平面(屏幕窗口、打印纸等)。这些物理平面的单位量化往往多种多样,比如像素、打印点、英寸、毫米等等。这样可能会造成很多混乱,所以CDC输出对所有物理平面进行统一抽象化为“逻辑平面”。所有CDC输出函数都是在逻辑平面输出。物理平面在CDC又称为设备平面。
所有CDC输出函数最终都是输出“点”。GDI使用坐标来抽象化表示这些“点”。逻辑平面和设备平面都使用直角坐标系作为输出依据。在逻辑平面上所有长度单位和面积单位都是纯粹的数学上的代数“1”,没有实际的物理单位(比如毫米、英寸、平方米等等)。
坐标是对CDC指定的设备进行“等分”用的。具体就是逻辑坐标是对逻辑平面进行逻辑等分,设备坐标是对设备平面进行设备等分。
逻辑坐标(Logical Coordinate)所在的平面本来该称为“逻辑平面”,但是最初系统开发者却用错单词,使用“窗口Window”。设备坐标(Device Coordinate)所在的平面本来该称为“设备平面”,但是最初系统开发者却用错单词,使用“视口Viewport”。这是CDC绘图的第1个难点。
画图在逻辑坐标中画,使用逻辑单位。显示图形在设备坐标中显示,使用设备坐标。
逻辑坐标上面的点叫做“逻辑点、数学点、几何点”
设备坐标上面的点叫做“设备点、物理点(比如显示点、屏幕点、打印点)”
点的位置用x和y两个坐标表示,x表示横坐标,y表示纵坐标。
GDI包括以下3种设备坐标:
(1)客户区坐标。框架窗口、对话框的客户区域(Client)。
(2)全窗口坐标。一般都会把“全”字省略。包括一个程序的整个窗口,包括标题条、菜单、滚动条、状态区和边框。使用GetWindowDC得到的全窗口设备环境。
(3)屏幕坐标。用函数ClientToScreen和ScreenToClient可以将客户区坐标转换成屏幕坐标,或反之。
客户区、全窗口、屏幕的左上角的点固定为(0,0)。这3种设备坐标都跟“显示”有直接关系。以后还有跟打印有关的设备坐标。
设备(物理)平面分为显示平面和打印平面。显示平面跟显示器等显示设备有关,打印平面跟打印机(包含虚拟打印机)等打印设备有关。
虽然经过抽象化使得CDC输出变得容易(逻辑平面单位单一),但是最后还是要输出要设备平面上。设备平面多种多样,对应的单位多种多样,很复杂。这样要求需要一种转换办法来处理逻辑平面到设备平面的差异。这种转换办法称为“映射”。映像指逻辑坐标与设备坐标之间的转换办法。比例映像模式本身也是设备环境的属性之一。
用SetMapMode函数设置新比例映像模式。
用GetMapMode函数获得当前比例映像模式。
MFC提供了8种比例映像模式,如下图
映像模式 |
1个逻辑单位 对应的设备单位 |
X轴正向方向 | Y轴正向方向 |
MM_TEXT | 1像素 | 右 | 下 |
MM_LOMETRIC | 0.1mm | 右 | 上 |
MM_HIMETRIC | 0.01mm | 右 | 上 |
MM_LOENGLISH | 0.01in | 右 | 上 |
MM_HIENGLISH | 0.001in | 右 | 上 |
MM_TWIPS | 1/1440in | 右 | 上 |
MM_ISOTROPIC | 用户自定义x=y | 用户自定义 | 用户自定义 |
MM_ANISOTROPIC | 用户自定义x!=y | 用户自定义 | 用户自定义 |
GDI包括以下2类3种比例映像模式:
①默认比例映像模式:
当不明确使用哪一种比例映像模式时候,GDI将会使用默认的映像比例映像模式——MM_TEXT。这个模式含义是:逻辑坐标和设备坐标是1:1的比例输出,也就是1个逻辑点正好就是1个设备点。这是最简单、易于理解的比例映像模式。如果输出平面是屏幕则1逻辑单位就是1像素点;如果是打印平面则1逻辑单位就是就是一个打印点。
在MM_TEXT模式下:
坐标轴向右方向是正方向
坐标轴向左方向是负方向
坐标轴向上方向是负方向
坐标轴向下方向是正方向
逻辑平面(0,0)的点永远与设备平面(0,0)的点是重合。这样最简单的绘图代码如下:
//main.h的内容
class CMyApp:public CWinApp
{
public:
virtual BOOL InitInstance();
};
class CMainWindow:public CFrameWnd
{
public:
CMainWindow();
protected:
afx_msg void OnPaint();
DECLARE_MESSAGE_MAP();
};
//main.cpp的内容
#include <afxwin.h>
#include "main.h"
CMyApp myApp;
BOOL CMyApp::InitInstance()
{
Enable3dControls();
m_pMainWnd=new CMainWindow;
m_pMainWnd->ShowWindow(m_nCmdShow);
m_pMainWnd->UpdateWindow();
return TRUE;
}
BEGIN_MESSAGE_MAP(CMainWindow,CFrameWnd)
ON_WM_PAINT()
END_MESSAGE_MAP()
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,350,350));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
dc.Ellipse(-100,-100,100,100);
}
代码“Ellipse(-100,-100,100,100);”的含义是绘制一个圆,但是窗口的客户区只显示右下角的1/4圆。这是坐标轴的负方向被屏蔽的原因。这时候可以用以下代码显示完整圆:
//main.cpp的内
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,450,450));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
dc.SetViewportOrg(100,100);
dc.Ellipse(-100,-100,100,100);
}
画出的圆如下:
以上代码有2处红字:
①上面Create函数的参数里面的改变是为了让窗口右边和下面有足够空间容纳圆。
②下面SetViewportOrg函数是改变设备平面“对齐点”的坐标。
所谓的“对齐点”是指逻辑平面映像到设备平面必须在2个平面里面各找一个固定点并对齐。只有这样“映射”才有实际意义。SetViewportOrg函数后面“Org”是“original point”的缩写,该单词意思是“原点”。“Viewport”本意是“视口”,这里应该是“Device”“设备”才适合。这是系统设计时候选单词的失误。绝大部分翻译都是如实翻译成“原点”,这样导致很多人错误理解。应该用类似“align”才对,比如SetDeviceAlignPoint之类。这是CDC绘图的第2个难点。
第2个也可以使用SetWindowOrg函数是改变逻辑平面“对齐点”的坐标。这个函数跟SetViewportOrg函数相似的问题。建议换成SetLogicalAlignPoint之类。
如果使用SetWindowOrg函数则代码如下:
dc.SetWindowOrg(-100,-100);
这样也可以达到同样效果。
②度量比例映像模式:
这类映像模式有5种,如下:
MM_LOMETRIC
MM_HIMETRIC
MM_LOENGLISH
MM_HIENGLISH
MM_TWIPS
他们的共同点是:
坐标轴向右方向是正方向
坐标轴向左方向是负方向
坐标轴向上方向是正方向
坐标轴向下方向是负方向
度量比例映像模式与默认比例映像模式的共同点:
逻辑平面原点(0,0)的点永远与设备平面原点(0,0)的点是重合。
不同点:
坐标轴在垂直方向是相反的。
这里以MM_LOENGLISH为例解释英制度量的影射模式。代码如下:
//main.cpp的内容
//…….省略
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
dc.SetMapMode(MM_LOENGLISH);
dc.Ellipse(0,0,100,-100);
}
画出的圆如下:
用系统自带的“画图”工具打开该图像,用放大镜放大6倍。选中铅笔工具,把铅笔分别移动到圆的最上方和最下方。这样会得到垂直坐标分别为32和158。这样圆的直径是127像素。在MM_LOENGLISH模式下,100逻辑单位就是1英寸,而系统有一个“正常尺寸(96dpi)”。这样显然不一样。GDI内部实际上使用的是126dpi。这实际上是系统设计bug。需要注意。
这里以MM_LOMETRIC为例解释公制度量的影射模式。代码如下:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,250,300));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
dc.SetMapMode(MM_LOMETRIC);
dc.Ellipse(0,0,100,-100);
}
画出的圆如下:
用系统自带的“画图”工具打开该图像,用放大镜放大6倍。选中铅笔工具,把铅笔分别移动到圆的最上方和最下方。这样会得到垂直坐标分别为32和81。这样圆的直径是50像素。在MM_LOMETRIC模式下,100逻辑单位就是1cm=50像素。而1英寸=2.54cm=127像素。
MM_TWIPS也类似,代码如下:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,400,400));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
dc.SetMapMode(MM_TWIPS);
dc.Ellipse(0,0,1440,-1440);
}
画出的圆如下:
用系统自带的“画图”工具打开该图像,用放大镜放大6倍。选中铅笔工具,把铅笔分别移动到圆的最上方和最下方。这样会得到垂直坐标分别为32和158。这样圆的直径是127像素。在MM_TWIPS模式下,1440逻辑单位就是1英寸,而1英寸=127像素。
可见,在GDI的CDC输出函数中,实际遵守127dpi而不是系统默认的96dpi。
③自定义比例映像模式
这类模式跟前面两类模式的差异是“自定义”可以改变逻辑范围和设备范围。
改变“范围”使用SetWindowExt函数和SetViewportExt函数。函数的后缀“Ext”是extent(范围、距离、长度)的缩写。
SetWindowExt是用逻辑坐标进行等分。SetViewportEx是用设备(物理)坐标进行设备(物理)等分。
当使用MM_ISOTROPIC或MM_ANISOTROPIC模式时候,必须2个函数都使用,不然可能会出现不可预知的错误或者任何CDC输出函数都会无效(没有任何像素输出)。
所谓“范围”跟设备(物理)平面有直接关系。比如一个屏幕分辨率是1024*768,那么SetViewportExt(1024*768) 意味着水平设备范围就是分成1024等分,垂直设备范围就是分成768等分。
“范围”跟逻辑平面有间接关系。当使用CDC输出函数时候定义多少逻辑单位可以铺满窗口,比如SetWindowExt(100,200)意味着水平100逻辑单位,垂直200逻辑单位可以铺满窗口。
这2个函数的Window和Viewport跟前面的“对齐点”有着一样的问题。
在《Windows程序设计》中文版P147里面有一个公式:
这个公式的原型其实是如下:
这样会有2个问题:
①窗口大小和客户区大小可能会不断变化而不稳定。这样就必须一直改变xWinExt和xViewExt以适应这样的变化。
②公式里面隐藏不可回避的乘法运算。把对齐点都设为0,这样最简化公式,如下:
公式里面所有变量实际上都是有符号整数。如此,乘法运算便是必须面对可能的积过大而溢出。
对于第1个问题的解决办法:对范围重新定义,不再是所谓的“等分”。从或
可知关键之处在于“比例的值”。因此可以都除以最大公约数以最简化,这样结果不变。
对于第2个问题的解决办法:在现实中,对齐点坐标和目标点坐标往往是不可改变的,因此只能改变范围。建议范围用都除以最大公约数,这样也会得到一样的结果。
在《Windows程序设计》中文版P148最上面段落的最后那句“每个范围本身没有多大意义,但是逻辑单位转换为设备单位的换算因子是视口范围和窗口范围的比例”。这说明GDI设计者已经发现这个问题,并按照以上2点做出解决办法。
总结:逻辑范围和设备范围实际是逻辑坐标和设备坐标的比例放大或缩小的作用。
这类模式有2种:MM_ISOTROPIC和MM_ANISOTROPIC。其中MM_ANISOTROPIC是原始形态。MM_ISOTROPIC是变种,简化前面的功能。个人建议不使用MM_ISOTROPIC。
在使用MM_ISOTROPIC或MM_ANISOTROPIC模式之后许使用SetViewportExt函数和SetWindowExt函数才能得到正确结果,否则可能会有不可预支错误发生或者绘画函数没有任何效果。
SetViewportExt函数和SetWindowExt函数的原型如下:
virtual CSize SetViewportExt( int cx, int cy ); //设备单位
virtual CSize SetViewportExt( SIZE size ); //设备单位
virtual CSize SetWindowExt( int cx, int cy ); //逻辑单位
virtual CSize SetWindowExt( SIZE size ); //逻辑单位
比如:
SetViewportExt(100,300);
SetWindowExt(200,400);
200/100=2/1=2 逻辑单位等于1设备单位,水平部分
400/300=4/3=1.3333逻辑单位等于1设备单位,垂直部分
反过来:
100/200=1/2=0.5 设备单位等于1逻辑单位,水平部分
300/400=3/4=0.75 设备单位等于1逻辑单位,垂直部分
比如,当要显示水平100逻辑单位时候,实际是水平50设备单位
先解释MM_ANISOTROPIC的用法。代码如下:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,450,350));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
dc.SetMapMode(MM_ANISOTROPIC);
dc.SetWindowExt(1,1);
dc.SetViewportExt(2,1);
dc.Ellipse(0,0,100,100);
}
画出的圆如下:
从上面看到水平有放大,垂直没有。
当使用MM_ISOTROPIC模式时候,cx和cy“实际”上是相等的。所谓的“实际上”是指即使输入参数不相等,但是函数内部也会自动修改输入参数使其相等。这个模式不会像MM_ANISOTROPIC那样会变形。
代码如下:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,400,400));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
dc.SetMapMode(MM_ISOTROPIC);
dc.SetWindowExt(400,150);
dc.SetViewportExt(200,300);
dc.Ellipse(0,0,300,300);
}
画出的圆如下:
上图的圆直径是150像素,可见SetWindowExt函数保留比较大的参数,SetViewportExt保留比较小的参数。
MM_ISOTROPIC模式的存在显得比较尴尬的,因为MM_ANISOTROPIC也可以做到。
注意:GDI不会真的按照100、200、300、400来进行等分,而是比例才是起着实际作用。用中间比例来也一样的效果,具体如代码如下:
SetViewportExt(1,3);
SetWindowExt(2,4);
而且这样有一个好处:如果参数太大,可能会乘法(CDC输出函数的参数很可能会跟上面的参数相乘)溢出,一般都是除去最大公约数。
如果SetWindowExt函数和SetViewportExt函数的参数都是1,那么输出的就是原始输出,结果如下:
这时1逻辑单位就是1设备单位(像素)。
注意事项:
①SetWindowExt函数和SetViewportExt函数只有在映像模式为MM_ISOTROPIC或MM_ANISOTROPIC时候才有实际作用。
②不管当前是哪个模式,最近的SetWindowOrg函数、SetViewportOrg函数函数总是有效的。
③6个已定义的映像模式仅对逻辑平面的坐标轴改变方向而不对设备平面坐标轴改变方向。
④在xp、2003系统上面1厘米50像素。但是在Vista、7、8系统上面1厘米26.7像素。这是系统设计的bug,无解。一般认为在相同模式下,逻辑单位一样长便认为物理单位一样长。
⑤SetWindowExt函数和SetViewportExt函数对设备平面坐标轴方向的改变仅仅适用于CDC的输出函数。其他方面还是按照向右、向下为正。这个在屏幕坐标更常见。
⑥在SetWindowExt函数和SetViewportExt函数之后,SetWindowOrg函数和SetViewportOrg函数的方向性是依据最近的SetWindowExt函数和SetViewportExt函数。
⑦使用窗口滚动条不会改变设备坐标,会改变逻辑坐标的对齐点。
最后、最隐蔽的bug:
以下代码为了让结果图案看的更直观,加入刷子,请看:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,450,450));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
CBrush brush1(HS_DIAGCROSS,RGB(0,255,0));
dc.SelectObject(&brush1);
dc.Ellipse(-100,-100,100,100);
dc.SetViewportOrg(100,100);
CBrush brush2(NULL,RGB(255,0,0));
dc.SelectObject(&brush2);
dc.SetBkMode(TRANSPARENT);
dc.Ellipse(-100,-100,100,100);
}
绘出的图案如下:
以上代码第一部分获得设备环境句柄。
第二部分画出左上角绿色的1/4圆。
第三部分画出红色圆。
从上图可以看出MFC的GDI是有1个bug的。那就是在第2部分使用SetViewportOrg函数设置新原点时候,新的映射会使得新圆覆盖掉旧圆。但显然旧圆还在。这是GDI设计者认为:任何CDC输出函数都是互相独立的——每次输出都是在“新”逻辑平面进行输出。这样在逻辑平面输出后都对新输出的像素根据CDC属性运算到已经存在的设备平面上。
SetMapMode函数本身不会影响已经在设备平面输出的点的分布。比如在上面代码最后加入SetMapMode(MM_HIENGLISH);语句,重新编译并运行,结果是一样的。
跟SetMapMode函数一样,SetWindowOrg函数、SetViewportOrg函数、SetWindowExt函数和SetViewportExt函数不会影响已经在设备平面输出的点的分布。比如在上面代码最后加入dc.SetViewportExt(300,300);语句,重新编译并运行档,结果是一样的。
GetDeviceCaps函数是一个很有用的函数。里面参数经常被用来查找跟CDC相关的环境信息。现在给出一个例子:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,420,500));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
int cHORZRES = dc.GetDeviceCaps(HORZRES);
int cVERTRES = dc.GetDeviceCaps(VERTRES);
int cHORZSIZE = dc.GetDeviceCaps(HORZSIZE);
int cVERTSIZE = dc.GetDeviceCaps(VERTSIZE);
int cLOGPIXELSX = dc.GetDeviceCaps(LOGPIXELSX);
int cLOGPIXELSY = dc.GetDeviceCaps(LOGPIXELSY);
int cNUMCOLORS = dc.GetDeviceCaps(NUMCOLORS);
int cBITSPIXEL = dc.GetDeviceCaps(BITSPIXEL);
int cPLANES = dc.GetDeviceCaps(PLANES);
int cRASTERCAPS = dc.GetDeviceCaps(RASTERCAPS);
int cTECHNOLOGY = dc.GetDeviceCaps(TECHNOLOGY);
CString sHORZRES;
CString sVERTRES;
CString sHORZSIZE;
CString sVERTSIZE;
CString sLOGPIXELSX;
CString sLOGPIXELSY;
CString sNUMCOLORS;
CString sBITSPIXEL;
CString sPLANES;
CString sRASTERCAPS;
CString sTECHNOLOGY;
sHORZRES.Format("%d",cHORZRES);
sVERTRES.Format("%d",cVERTRES);
sHORZSIZE.Format("%d",cHORZSIZE);
sVERTSIZE.Format("%d",cVERTSIZE);
sLOGPIXELSX.Format("%d",cLOGPIXELSX);
sLOGPIXELSY.Format("%d",cLOGPIXELSY);
sNUMCOLORS.Format("%u",cNUMCOLORS);
sBITSPIXEL.Format("%d",cBITSPIXEL);
sPLANES.Format("%d",cPLANES);
sRASTERCAPS.Format("%d",cRASTERCAPS);
sTECHNOLOGY.Format("%d",cTECHNOLOGY);
dc.TextOut(10,20,CString("HORZRES"));
dc.TextOut(10,40,CString("VERTRES"));
dc.TextOut(10,60,CString("HORZSIZE"));
dc.TextOut(10,80,CString("VERTSIZE"));
dc.TextOut(10,100,CString("LOGPIXELSX"));
dc.TextOut(10,120,CString("LOGPIXELSY"));
dc.TextOut(10,140,CString("NUMCOLORS"));
dc.TextOut(10,160,CString("BITSPIXEL"));
dc.TextOut(10,180,CString("PLANES"));
dc.TextOut(10,200,CString("RASTERCAPS"));
dc.TextOut(10,220,CString("TECHNOLOGY"));
dc.TextOut(120,20,sHORZRES);
dc.TextOut(120,40,sVERTRES);
dc.TextOut(120,60,sHORZSIZE);
dc.TextOut(120,80,sVERTSIZE);
dc.TextOut(120,100,sLOGPIXELSX);
dc.TextOut(120,120,sLOGPIXELSY);
dc.TextOut(120,140,sNUMCOLORS);
dc.TextOut(120,160,sBITSPIXEL);
dc.TextOut(120,180,sPLANES);
dc.TextOut(120,200,sRASTERCAPS);
dc.TextOut(120,220,sTECHNOLOGY);
}
结果如下图:
最真实的dpi的获得办法:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,300,300));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
CRect rect;
GetClientRect(&rect);
int xLogPixPerInch = dc.GetDeviceCaps(LOGPIXELSX);
int yLogPixPerInch = dc.GetDeviceCaps(LOGPIXELSY);
CString strxLogPixPerInch;
CString stryLogPixPerInch;
strxLogPixPerInch.Format("%d",xLogPixPerInch);
stryLogPixPerInch.Format("%d",yLogPixPerInch);
dc.TextOut(40,20,strxLogPixPerInch);
dc.TextOut(40,40,stryLogPixPerInch);
}
如果操作系统在桌面属性的“设置”→“高级”→“常规”→“显示”→“dpi设置”把dpi设置成默认的96时候获得的结果如下图:
如果是设置为120那么结果如下图:
里面的字都变得比较大一些。
GetClientRect函数获得的客户区情况,代码如下:
//main.cpp的内容
//…….省略
CMainWindow::CMainWindow()
{
Create(NULL,_T("MFC GDI"),WS_OVERLAPPEDWINDOW,CRect(200,200,350,350));
}
void CMainWindow::OnPaint()
{
CPaintDC dc(this);
CRect rect;
GetClientRect(&rect);
CString oldRight;
CString oldBottom;
oldRight.Format("%d",rect.right);
oldBottom.Format("%d",rect.bottom);
dc.TextOut(40,20,oldRight);
dc.TextOut(40,40,oldBottom);
dc.SetMapMode(MM_LOMETRIC);
GetClientRect(&rect);
oldRight.Format("%d",rect.right);
oldBottom.Format("%d",rect.bottom);
dc.TextOut(40,-130,oldRight);
dc.TextOut(40,-160,oldBottom);
}
结果如下图:
可见,映射模式并不会改变GetClientRect函数获得的客户区大小与设备坐标方向(oldRight和oldBottom都是正数可见设备坐标方向没被改变),也不会改变字体大小,但依然可以改变逻辑方向性。即使用鼠标拖动边框改变窗口大小也是上下一致。
根据经验,最好别在SetViewportExt函数参数里面使用负数,改变坐标轴方向最好是在SetWindowExt函数里面比较保险。这2个函数的x比例的正负号决定逻辑坐标轴的右方向的正负号。这2个函数的y比例的正负号决定逻辑坐标轴的下右方向的正负号。
①可以认为CDC的所有成员函数都以逻辑坐标为参数。
②可以认为CWnd的所有成员函数都以设备坐标为参数。