65.9K
CodeProject 正在变化。 阅读更多。
Home

文章四: 在 C# 中构建 UI 平台 - 将文本绘制到像素

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.58/5 (23投票s)

2005年2月24日

10分钟阅读

viewsIcon

184981

downloadIcon

1958

GDI+:掌控 MeasureString、DrawString 和 MeasureCharacterRanges。

Article Selector

文本变得难看

即使使用 GDI+ 及其“无设备”的文本处理方法,在精确绘制文本时我们仍然面临困境。显然,这是因为作为 UI 构建者,我们发现自己处于文本渲染领域的一个子集:GUI 文本。GUI 文本是一个美妙的术语,据我们所知,它首次由 Pierre Arnaud 在这篇 CodeProject 文章中引入。该文章描述了一种本质上是*副作用文本测量*的技术。其思想是您在白色位图上绘制黑色文本,然后逐像素扫描位图,直到找到非白色像素。通过这种方式,您可以测量文本在屏幕像素中的确切大小。从定义上讲,这种方法总是能得出正确的结果——怎么会不呢?因为我们正在检查 GDI+ 实际做了什么,而不是它说要做什么(即,请参阅 `MeasureString`,甚至是 `MeasureCharacterRanges`)。

GUI 文本定义

回到那个美妙的术语:GUI 文本。我们将 GUI 文本定义为严格为了增强应用程序可用性而定义、定位和绘制的文本。它不是文档的一部分。它不是为了打印。它只是为了帮助用户并使其他控件的功能显而易见。

那么,让我们用“功能”来定义我们在 GUI 文本中寻找什么

  1. 像素级定位(精确的高度和宽度),就像所有其他图形基元一样。
  2. 在所有磅值下都具有有效的抗锯齿(最好是像 Adobe PhotoShop 中的平滑 - 锐利 - 清晰 - 强劲那样,即使在小字体尺寸下也能很好地工作)。
  3. 动画,文本可以像 Macromedia Flash 中那样移动。

嗯,对于第 2 和第 3 项,别抱太大希望。微软似乎很可能会在 Avalon 中给我们更多的“黄金 API”来尝试这些。不过,这其实是一个无关紧要的问题,因为仅仅实现第 1 项在 .NET 框架中就已经是一个大难题了。原因如下:

MeasureString 的困境

  1. DrawString 在渲染的文本前后添加额外的空间。
  2. MeasureString 忠实地返回这个额外的空间。
  3. GenericTypographic 不能用于渲染文本——至少不能渲染清晰的 GUI 文本。
  4. GenericTypographic 通常被建议作为准确测量文本的方法,虽然它好一点(它消除了文本*之后*的空间),但由于第 3 点,这实际上无关紧要。
  5. MeasureCharacterRanges 也做同样的事情,但需要你做更多的前期工作才能调用它。

关于“黄金字体”(如果你生活在 Windows XP 中,它是 Tahoma 8 常规字体)的一些经验证据

这些测试中有一半使用默认字体分辨率(96 dpi),另一半使用大字体(现在在 Windows XP 中简称为 120 dpi)。蓝色矩形表示由 `MeasureString` 或 `MeasureCharacterRanges` 返回的大小。红色数字表示蓝色矩形边缘和测试字符串边缘之间出现的空白量(测试字符串“Wello jelly”之所以被选中,是因为它的第一个字符非常宽,并且存在升部和降部)。从 GUI 文本的角度来看,每种组合都失败了。

StringFormat 中有足够的设置,MeasureStringDrawString 上有足够的重载,甚至 Graphics 对象上还有一个可爱的小 TextRenderingHint 属性,足以让你为此问题工作数天。但是,无论你尝试什么,那个蓝色矩形永远不会触及文本的每个边缘。我们决定放弃与技术的抗争。真正的屏幕像素测量似乎在 GDI+ 中不可用,这使我们得出结论,通过副作用测量文本是唯一可用的解决方案,除非使用一些 P/Invoke 工作,而我们希望避免这种情况。

新的 Label 控件

那么我们追求的是什么呢?新的 `Label` 控件应该支持单行或多行文本,左对齐、居中、右对齐或完全两端对齐。文本的四边都应该有边距。`AutoSize` 应该存在,并且默认情况下宽度和高度都应为 true。一个可插拔的边框,可以改变文本换行可用的内部矩形,也会很不错。

这引出了一些高级测试

  1. 左边距 10
  2. 右边距 10
  3. 上边距 10
  4. 下边距 10
  5. Tahoma 8 粗体
  6. Tahoma 8 斜体
  7. Tahoma 8 粗体和斜体
  8. 更改字体为 Tahoma 10
  9. 更改文本
  10. 更改位置
  11. AutoSize 关闭,减小宽度以截断
  12. AutoSize 关闭,减小高度以截断
  13. AutoSize 关闭,右对齐,增加宽度
  14. AutoSize 关闭,居中,增加宽度
  15. AutoSize 关闭,两端对齐,增加宽度

编写这些测试非常简单,编写新的声明性类来支持功能也一样。我们的新标签模型如下:

  • ControlProperty – 表示控件中使用的任何属性,声明一个 Changed 事件。
  • ControlState – 包含一个 ControlProperty 对象池。
  • LabelController – 监听任何给定 ControlProperty 的更改,并将更改路由到相应的 LabelBot 进行处理。
  • LabelBot – 响应单个属性的更改,封装文本,计算边界并刷新标签。
  • VisibleBot – 当 Visible 属性值更改时显示或隐藏标签。
  • TextModel – 包含被封装文本的基于对象的描述。
  • Line – 单行文本。
  • Word – 单个单词文本。
  • Character – 单个字符文本。
  • RectangleBounds 的后代,定义一个矩形区域。
  • Point – 实现 RectangleLocation 属性,包含通知事件。
  • Size – 实现 RectangleSize 属性,也包含通知事件。

图中未包含许多 `ControlProperty` 和 `LabelBot` 的派生类。`VisibleBot` 在此作为属性和机器人的具体示例。在用 MVC 建模控件时,我们注意到应用程序逻辑似乎集中在控制器中,导致类复杂。通过隔离对控件进行的每个单独更改并实现一个机器人派生类来处理该更改,我们能够分散维护控件完整性所需的所有逻辑。`LabelController` 只需要挂接每个属性的 `Changed` 事件。当事件触发时,一个机器人被实例化并调用。机器人是模态的,即一次只能有一个机器人处于活动状态。这是必要的,因为机器人通常会触发控件中的进一步更改:非模态实现将导致堆栈错误。

机器人对 Label 所做的大部分工作都涉及文本换行(或重新换行)。编写文本换行引擎并不太困难,除非您需要知道字符串中每个字符的位置。我们的研究表明,GDI+ 将每个单词视为一件艺术品。也就是说,字符间距可以因单词而异——即使两个单词包含相同的字母组合。例如,“rav”和“bravo”,当由 GDI+ 绘制时,“a”和“v”之间的间距不同。当需要移动插入符号时(很快就会到来),如果情况如此,我们将遇到严重问题。那么,一种折衷解决方案是绘制每个字符,使用 GDI+ 给出的相邻字符的推荐间距。这种方法让我们完全控制字符间距,但牺牲了文本范围(整个单词)的精确 GDI+ 字距调整。我们认为这是一个值得做的权衡,因为这是 GUI 文本(不是所见即所得文本),而且生成的 TextModel 无论如何都让我们完全控制字距调整。

文本引擎

以下是文本引擎的实现方式:

文本换行过程的结果是 `TextModel`。`TextModel` 是文本的基于对象的描述,精确到最后一个字符(以及每个字符之间的间距)。这种详细程度对于 `Label` 控件来说并非严格必要(我们只需要 `Line` 对象就可以),但 `EditBox` 需要字距调整,所以我们直接实现了完整的解决方案。右侧的 `FontMetric` - `MasterCharacter` - `KerningPair` 类是一个享元渲染,包含给定字体(名称、大小、样式)的度量。组装 `TextModel` 的实际工作是在 `TextWrapper` 类中完成的,该类使用 `TextRuler` 进行所有测量,将文本分解为行和单词。`LabelController` 使用 `TextJustifier` 进一步处理 `TextModel`,根据 `TextAlignment` 调整每行中的单词。

在测试我们的字符放置方法和 GDI+ 之间的差异时,我们有些狂热。我们测试了五种不同的字符串,使用了几种不同的字体。因此,仅仅测试字体绘制就有大约 1,500 种情况。好的一面是,我们的技术看起来很可靠:它与 GDI+ 字距调整没有太大差异。(如果您下载源代码,您会注意到这些测试不存在——它们只是使下载文件过大。)

新的标签控件,使用 Tahoma 8 Regular 字体,完全对齐,带一个像素的边框(蓝色)和两个像素的边距(浅蓝色)。请注意第一行上的升部和最后一行上的降部如何触及边距边缘。

我们现在已经为开发 `EditBox` 控件做好了战略准备——这将是下一篇文章的主题。

研发

我们能达到这种实现并非没有经历一些严重的弯路。对我们来说,坚持使用 GDI+ 而不依赖 `ExtTextOut` 和 `GetABCCharWidths` 至关重要。这不仅因为我们试图将所有内容都保持在原生 .NET 中,还因为对 Unicode 支持的担忧。除了 P/Invoke 方法,我们还考虑了以下选项:

  1. 一种游戏开发人员的方法,即从 `Bitmap` 中将单个字符渲染到屏幕上。我们也在 CodeProject 上找到了一篇关于此的文章,以及互联网上其他一些不错的选择

    最终,我们无法证明为 UI 中使用的每种字体面创建位图是合理的,这似乎开销太大了。

  2. 一种反向“抹白”实现。我们通过在 `Bitmap` 上绘制整个单词,然后使用为每个字符专门创建的遮罩 `Bitmap` 擦除单词的每个字母(从后往前)来扩展“副作用测量方案”。这种技术为我们提供了任何给定单词中使用的精确字符间距——但当字母字距调整重叠时,它就失效了。尽管有解决该问题的方法,但字符遮罩的创建明显减慢了速度,因此我们放弃了这种方法。
  3. 深入研究 FreeType 开源项目,尤其是该产品的文档,对我们帮助很大。阅读这些材料应该能让你清楚地认识到渲染文本并非易事,并且先验地计算 GDI+ 渲染结果是绝对不可能的。正是 FreeType 文档最终让我们决定采用副作用测量变体作为解决方案。

毋庸置疑,GDI+ 使用的字距调整算法非常难以处理,特别是当单词形成时字距调整发生变化的行为。我们的最终方法通过锁定字母组合首次使用的字距调整来解决这个问题。这应该可以防止文本在 `EditBox` 中输入时“跳动”。

项目统计

由于字距调整情况导致的测试激增。

下载次数

链接

  • Petzold - 第 359 页专门介绍文本和字体。
  • Microsoft - 关于 GDI+ 字符串渲染...
© . All rights reserved.