Windows API SetTextAlign之我所见

一、积重难返!

  • 无论你有没有意识到,在Windows程序设计中,在消息WM_PAINT中输出文本的时,在使用SetTextAlign函数来控制文本对齐的时候,总会有些别扭,说的更直白些是对SetTextAlign函数如何控制文本对齐的原理感到疑惑。有时候为了试图搞清楚其这种控制对齐的原理,我们不惜把SetTextAlign的参数改来改去,但却造成了更多的疑惑:为何第一行总和之后的行格式不一样?为何整整少了一列?为何我改了参数什么事情都没有发生?是我眼睛花了吗(没准这是事实,你可能很快就可以意识到)?
  • 于是我们决定跳过这个陷阱,暂且不管“文本对齐”这档子事,不过不久就会发现,在窗口的客户区对文本的输出操作实在过于频繁,每一次我们都得承受这种疑惑带来的苦恼,甚至让我们感到羞耻。终于我们打算直面这个问题,下面我们一起来找些灵感。

二、正式开始前的准备…

  • 我们首先需要引用一段经典的代码段来作为下面讨论的基础,下面这段代码引自大师Petzold的著作《Windows程序设计》4.2.10节的SYSMETS1.C源码:
//
// WinMain函数定义
// 
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR szCmdLine, int iCmdShow) {
	// 
	// 窗口类定义
	// 注册窗口类
	// 创建并更新窗口
	// 进入消息循环
	// 其他细节...
	//
	return msg.wParam;
}

//
// 窗口过程函数定义
//
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
	static int		cxChar, cxCaps, cyChar;
	HDC				hdc;
	int				i;
	PAINTSTRUCT		ps;
	TCHAR			szBuffer[10];
	TEXTMETRIC		tm;

	switch (message) {
	// 创建窗口(初始化)消息处理
	... 省略 ...
	
	// 绘制客户区消息处理
	case WM_PAINT:
		hdc = BeginPaint(hwnd, &ps);

		for (i = 0; i < NUMLINES; ++i) {
			TextOut(hdc, 0, cyChar * i, 
					sysmetrics[i].szLabel,
					lstrlen(sysmetrics[i].szLabel));

			TextOut(hdc, 22 * cxCaps, cyChar * i,
					sysmetrics[i].szDesc,
					lstrlen(sysmetrics[i].szDesc));	
			// 第一次使用 9SetTextAlign
			SetTextAlign(hdc, TA_RIGHT | TA_TOP);
			
			TextOut(hdc, 22 * cxCaps + 40 * cxChar, cyChar * i, szBuffer,
					wsprintf(szBuffer, TEXT("%5d"),
					GetSystemMetrics(sysmetrics[i].iIndex)));
			// 第二次使用 SetTextAlign
			SetTextAlign(hdc, TA_LEFT | TA_TOP);
		}
		EndPaint(hwnd, &ps);
		return 0;

	// 销毁窗口消息处理
	... 省略 ...
	
	return DefWindowProc(hwnd, message, wParam, lParam);
}
  • 上面的代码段中我们省略了很多内容,仅仅保留了WM_PAINT消息的处理细节,这可以让我们把注意力放在我们最关心的问题上。我之所以敢这么做,我觉得在读我这些文字之前你至少应该了解过本书。
  • 即使不了解这段程序的具体细节(这很不利于下面我们达成共识),至少我们应该明白它的目的,这段程序的主要目的是输出三列文本,其中for循环中的每个TextOut函数对应具体的一列文本输出;这段程序需要文本对齐的原因在于最后一列数字的输出,我们控制其输出宽度占5列(%5d),并且想让这些数字右对齐。
  • 等等,你或许意识到了什么,根据C语言的语法规则,我们既然已经控制了数字的输出占5列,那么默认就是右对齐,然后在5列中剩余的列补空格,按照这种思路,现在的输出应该已经符合我们的要求,所以我们什么也不需要做!
    问题就是出在这里,因为即使在变宽字体中,0~9这10个数字宽度仍然是一样的,但是这10个数字却比空格要宽,所以我们如果不加任何控制的话,结果就像下面那样:
    Windows API SetTextAlign之我所见
  • 注意,为了有助于文本对齐的理解,这里我故意让客户区的背景颜色(亮灰色)和文本的背景颜色(白色)不一致,客户区的背景颜色和文本的背景颜色不是一个概念,难道不是吗?
  • 上面的结果中暴露出了问题,按照我们对C语言输出控制的理解,第三栏的数字应该会保持右对齐,并且每一行的白色条的宽度也应该是一致的(都是5列的宽度)。但是现在结果却出人意料。当然我们之前已经指出了问题所在:因为空格的宽度比0~9这10个数字的宽度要小

三、SetTextAlign函数影响了谁?

  • 在正式解决SetTextAlign函数控制文本对齐的方法之前,我们还有一个问题要弄清楚,我们为什么要把SetTextAlign函数放在那两个位置(源码中我已经在使用SetTextAlign函数的两个位置加了注释)。换句话说,SetTextAlign函数放在那两个位置各自影响了哪些文本的对齐方式?
  • 如果你接触过DirectX和OpenGL中的类似的编程模式,这里SetTextAlign的影响方式很像前者对编程上下文中对某些状态的改变方式(如果没有,请忽略这句话:))。简单理解,我们可以把SetTextAlign函数的影响看成是一种持续的过程,使用了SetTextAlign函数就像我们改变了一个全局的标志变量,以后程序再判断应该使用哪种文本对齐方式的时候就会来读这个全局标志变量,然后根据这个标志来确定文本对齐的方式。这种影响方式自然而然的产生了一个结果,就是如果我们在程序的某处调用了SetTextAlign函数,从此处开始,以后所有要输出的文本都会按此处设定的文本对齐方式来显示,直到我们再次调用SetTextAlign函数来修改成另一种文本对齐方式为止。
  • 对于我们这个程序,SetTextAlign函数影响的输出文本是这样的(只截取了for循环部分):
for (i = 0; i < NUMLINES; ++i) {
		TextOut(hdc, 0, cyChar * i, 
				sysmetrics[i].szLabel,
				lstrlen(sysmetrics[i].szLabel));
	
		TextOut(hdc, 22 * cxCaps, cyChar * i,
				sysmetrics[i].szDesc,
				lstrlen(sysmetrics[i].szDesc));	
	
		SetTextAlign(hdc, TA_RIGHT | TA_TOP);
		
		//
		// 这之间所有的输出文本都会按TA_RIGHT | TA_TOP的方式对齐
		//
	
		SetTextAlign(hdc, TA_LEFT | TA_TOP);
		//
		// 这之后的所有输出文本都会按TA_LEFT | TA_TOP的方式对齐
		// 包括进入下一次循环
		//
	}

因为Windows默认是使用TA_LEFT | TA_TOP这种文本对齐方式,所以在首次进入for循环的时候,前两个TextOut的输出的文本对齐方式也遵循第二个SetTextAlign函数的控制格式,这从而形成了一个闭环。

四、SetTextAlign函数控制文本对齐的方法

  • 经过前面的解释,我们已经大致明白产生文本参差不齐的原因,以及SetTextAlign函数可以影响哪些输出的文本,那么是时候探讨一下SetTextAlign函数影响文本对齐的具体方法了。
  • 首先,我想让你思考一个问题:现在你有一个矩形的纸片,我让你把它放在下面二维坐标系的原点(0,0)处,你有几种放置的方法?
  1. 你可能首先会像下面这样放置:
    Windows API SetTextAlign之我所见2. 你会觉得这看起来很完美,因为关于每条坐标轴,这个卡片都是对称的。你可能突然觉得有些不对劲,因为我问你的是几种?作为正常的出题思路,结果肯定不止一种,于是你又把它做了稍许平移得出了另外4种:
    Windows API SetTextAlign之我所见3. 最终结果的种类取决于你以矩形卡片上的哪一个点作为参考点(reference point)。终于你意识到了问题的所在,因为这个矩形卡片是有面积的,更精确的说这个矩形卡片是由无数个点构成的,如果只是指定放置的坐标点,让你做出判断,这种问题太模糊了,那一定是无数种,或者说是不能确定。
  • 有的人或许早就看出了这里的问题,这不就是锚点吗!锚点这个概念在计算机领域尤其是图形领域使用非常广泛,例如在Web页面的布局,游戏开发中菜单的布局都会频繁用到。
  • 如果想要把一个有大小(起码不止一个点)的物体放到一个位置上,仅仅指定这个位置是不够的,还需要一个锚点,也就是物体本身上的参照点。目的位置和锚点共同确定了放置情况的唯一性。
  • 有了上面锚点的概念,这里SetTextAlign函数对文本对齐的控制方法就好理解的多,因为每个字符周围都有一个矩形区域(我们称之为‘字符框’),一串字符构成的字符串就形成了一个矩形“长条”,如果要把这个字符串形成的矩形长条放置在窗口的客户区,其实和上面的卡片放置问题是一样的。SetTextAlign函数的第二个参数指定的就是矩形长条的锚点。从MSDN的Windows API参考文档中也可以看出这一点:
// Microsoft 对SetTextAlign函数第二个参数的解释
WINGDIAPI UINT WINAPI SetTextAlign(
  HDC hdc,
  UINT fmode
);
/*fmode
Unsigned integer that specifies the text alignment by using a mask of 
the values. The following table shows the possible values. You can 
combine only one of the values that affects horizontal and with only
one of the values that affects vertical alignment. In addition, you
can combine the horizontal and vertical values with only one of the
two flags that alter the current position. The default values are
TA_LEFT, TA_TOP, and TA_NOUPDATECP.*/

// fmode其中一种取值的解释
|Value       |Description                                            
 TA_BOTTOM    The reference point is on the bottom edge of the bounding rectangle.

注意上面提到了一个词“reference point”。然后我们看一下对于输出函数TextOut中坐标参数的解释:

// TextOutA 函数
BOOL TextOutA(
  HDC    hdc,
  int    x,
  int    y,
  LPCSTR lpString,
  int    c
);
// 坐标参数解释
x

The x-coordinate, in logical coordinates, of the reference point that 
the system uses to align the string.

y

The y-coordinate, in logical coordinates, of the reference point that 
the system uses to align the string.

这里同样提到了reference point这个词。细心理解你会发现,这里的位置参数x和y就文本要放置的位置,而SetTextAlign函数中的参数fmode就是文本自身的锚点。

五、实践是检验真理的唯一标准

我们需要几个小实验来验证并进一步理解SetTextAlign函数用锚点和放置位置来确定输出文本对齐方式的过程。

5.1 加上那两个SetTextAlign函数结果会怎样?

Windows API SetTextAlign之我所见

  • 之前我们曾把那两个位置的SetTextAlign函数去掉,结果是最后栏的数字文本在水平方向上打印的参差不齐;现在它们已经实现了右对齐,让我们稍微分析一下这个过程:我们在每次循环中打印数字之前,也就是第三个TextOut函数之前加入了SetTextAlign函数,这个函数要求字符串的右上角作为锚点,也就是说字符串右上角这个点需要和TextOut函数中指定的位置坐标(x,y)需要重合。又因为每次输出数字时放置的水平坐标x是固定的,所以他们实现了右对齐。
    Windows API SetTextAlign之我所见

5.2 去掉第二个SetTextAlign函数会怎样?

Windows API SetTextAlign之我所见

  • 没错这在我们的预料之内,Windows默认是以左上角(TA_LEFT | TA_TOP)为锚点,所以第一次循环和5.1中的过程是一样的,在循环中间我们把对齐方式调整到了右上角(TA_RIGHT | TA_TOP),由于我们去掉了循环结尾那个有着“复位”功能的SetTextAlign函数,所以从第二次循环开始,一切就变得不同了,第一栏文本并没有消失,只是为了适应右上角和放置位置(0,0)重合而跑到了窗口外面。

5.3 在5.1的基础上把第二个SetTextAlign函数中的TOP改成BOTTOM会怎样?

Windows API SetTextAlign之我所见

  • 没有变化?错!我早就说过,你的大脑会欺骗你的眼睛。三个栏目的排列的确与之前一模一样,但是注意第一行的内容,前两栏的字符串被替换了,不,更精确的说是被原来的第二行覆盖了。这完全证实了我们的锚点理论,首次进入循环,第一行的前两栏内容像以前那样打印出来,但是到了第一次循环快结束的时候,锚点被从左上角换到了左下角,所有之后的循环中,输出的文本都会向上移动自己的高度,从而把前一行给覆盖掉了。
    Windows API SetTextAlign之我所见
  • 理解了上面这三种情况,也就认清了SetTextAlign函数实现文本对齐的方法。简单的总结一下:锚点和放置点共同决定了文本在客户区的精确位置(对齐方式),SetTextAlign函数相当于确定了锚点在文本串的哪个位置,而具体的文本绘制函数(如TextOut)确定了文本在客户区放置的位置,SetTextAlign函数通过持续的影响模式来控制其后文本输出的对齐方式,直到使用SetTextAlign函数指定一个新的对齐方式为止。

六、结束前的一点思考

  • 我们为了搞清楚与文本对齐方式有关的问题,啰嗦了这么多值不值的?这取决于看待问题的角度。遇到问题是为了更好的解决问题,而问题本身不值得我们过分留恋。