借助 C++ 进行 Windows 开发

使用 DirectWrite 和最新 C++ 管理字体

Kenny Kerr

DirectWrite 是一种相当强大的文本布局 API。它支持从 XAML 和 Office 2013 的 Windows 运行时 (WinRT) 实现到 Internet Explorer 11 和更高版本的几乎所有领先 Windows 应用程序和技术。它本身并不是呈现引擎,但与 Direct2D 有很近的关系,是 Direct2D 在 DirectX 系列中的同级产品。当然,Direct2D 是首要的硬件加速即时模式图形 API。

您可以将 DirectWrite 与 Direct2D 结合使用,以提供硬件加速的文本呈现。说明一下,之前我在 DirectWrite 方面的著述并不多。我不希望您认为Direct2D 只是 DirectWrite 呈现引擎。Direct2D 远不只如此。DirectWrite 还有其他很多功能,在本月的专栏中,我将演示一些使用 DirectWrite 可以完成的任务,看看最新 C++ 是如何帮助简化编程模型的。

DirectWrite API

我将使用 DirectWrite 探究系统字体集。首先,我需要获取 DirectWrite 工厂对象。这是编写任何要使用 DirectWrite 的出色排版功能的应用程序的第一步。与大多数 Windows API 相同,DirectWrite 也依赖于 COM 基础内容。我需要调用 DWriteCreateFactory 函数来创建 DirectWrite 工厂对象。此函数返回一个指向该工厂对象的 COM 接口:

 

ComPtr<IDWriteFactory2> factory;

IDWriteFactory2 接口是今年早些时候随 Windows 8.1 和 DirectX 11.2 推出的最新版本 DirectWrite 工厂接口。 IDWriteFactory2 继承自 IDWrite­Factory1,而 IDWrite­Factory1 继承自 IDWriteFactory。 后者是原始的 DirectWrite 工厂接口,它公开了大部分工厂功能。

我将基于前面的 ComPtr 类模板调用 DWriteCreateFactory 函数:

HR(DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED,
  __uuidof(factory),
  reinterpret_cast<IUnknown **>(factory.GetAddressOf())));

DirectWrite 包含一项名为 Windows 字体缓存服务 (FontCache) 的 Windows 服务。 第一个参数指示获得的工厂是否参与此跨过程缓存的字体使用。 有 DWRITE_FACTORY_TYPE_SHARED 和 DWRITE_FACTORY_TYPE_ISOLATED 两个选项。 SHARED 和 ISOLATED 工厂都可以利用已缓存的字体数据。 只有 SHARED 工厂向缓存返回字体数据。 第二个参数具体指示我希望在第三个和最后一个参数中返回哪个版本的 DirectWrite 工厂接口。

在 DirectWrite 工厂对象给定的情况下,我可以直接要求其提供系统字体集:

ComPtr<IDWriteFontCollection> fonts;
HR(factory->GetSystemFontCollection(fonts.GetAddressOf()));

GetSystemFontCollection 方法的第二个参数是可选的,指示其是否检查已安装字体集的更新或更改。 幸运的是,这个参数默认为 false,因此,除非要确保结果集反映最近的更改,否则不必考虑它。 在字体集给定的情况下,我可以获取集合中的字体系列数,如下所示:

unsigned const count = fonts->GetFontFamilyCount();

然后我使用 GetFontFamily 方法通过从零开始的索引检索单个字体系列对象。 一个字体系列对象表示这样一组字体:它们共享一个名称,当然也是一种设计,但粗细、样式和拉伸并不相同:

ComPtr<IDWriteFontFamily> family;
HR(fonts->GetFontFamily(index, family.GetAddressOf()));

IDWriteFontFamily 接口继承自 IDWriteFontList 接口,因此,我可以枚举该字体系列中的各种字体。 能够检索字体系列名称合乎情理并且非常有用。 不过,系列名称已经过本地化,所以它并不像您期待的那样简单直接。 首先,我需要字体系列提供一个本地化字符串对象,该对象针对每种支持的区域设置均包含一个系列名称:

ComPtr<IDWriteLocalizedStrings> names;
HR(family->GetFamilyNames(names.GetAddressOf()));

我也可以枚举系列名称,但一般只查找用户默认区域设置对应的名称。 事实上,IDWriteLocalizedStrings 接口提供了 FindLocaleName 方法来检索本地化系列名称的索引。 我从调用 GetUserDefaultLocaleName 函数以获取用户默认区域设置开始:

wchar_t locale[LOCALE_NAME_MAX_LENGTH];
VERIFY(GetUserDefaultLocaleName(locale, countof(locale)));

然后,我将默认区域设置传递给 IDWriteLocalizedStrings FindLocaleName 方法,确定该字体系列是否有针对当前用户本地化的名称:

unsigned index;
BOOL exists;
HR(names->FindLocaleName(locale, &index, &exists));

如果请求的区域设置在集中不存在,我可能会回到一些默认设置,如“en-us”。假设存在,就可以使用 IDWriteLocalizedStrings GetString 方法获取副本:

if (exists)
{
  wchar_t name[64];
  HR(names->GetString(index, name, _countof(name)));
}

如果担心长度,可以先调用 GetString­Length 方法。 只要确保缓冲区足够大就可以。 图 1 提供了一个完整列表,显示所有内容是如何共同枚举已安装字体的。

图 1 使用 DirectWrite API 枚举字体

ComPtr<IDWriteFactory2> factory;
HR(DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED,
  __uuidof(factory),
  reinterpret_cast<IUnknown **>(factory.GetAddressOf())));
ComPtr<IDWriteFontCollection> fonts;
HR(factory->GetSystemFontCollection(fonts.GetAddressOf()));
wchar_t locale[LOCALE_NAME_MAX_LENGTH];
VERIFY(GetUserDefaultLocaleName(locale, _countof(locale)));
unsigned const count = fonts->GetFontFamilyCount();
for (unsigned familyIndex = 0; familyIndex != count; ++familyIndex)
{
  ComPtr<IDWriteFontFamily> family;
  HR(fonts->GetFontFamily(familyIndex, family.GetAddressOf()));
  ComPtr<IDWriteLocalizedStrings> names;
  HR(family->GetFamilyNames(names.GetAddressOf()));
  unsigned nameIndex;
  BOOL exists;
  HR(names->FindLocaleName(locale, &nameIndex, &exists));
  if (exists)
  {
    wchar_t name[64];
    HR(names->GetString(nameIndex, name, countof(name)));
    wprintf(L"%s\n", name);
  }
}

最新 C++ 浅谈

如果您经常阅读本杂志,就会知道我已经介绍过 DirectX,特别是 Direct2D 的最新 C++ 处理。 dx.h 头文件 (dx.codeplex.com) 也包含 DirectWrite。 可以用它大大简化我在上面提供的代码。 不必调用 DWriteCreateFactory,我可以从 DirectWrite 命名空间直接调用 CreateFactory 函数:

auto factory = CreateFactory();

获取系统字体集同样简单:

auto fonts = factory.GetSystemFontCollection();

枚举此字体集是 dx.h 的真正亮点。 我不必编写传统的 for 循环, 也不必调用 GetFontFamilyCount 和 GetFontFamily 方法, 我只需要编写一个最新的基于范围的 for 循环:

for (auto family : fonts)
{
  ...
}

这些代码与以往相同。 编译器(在 dx.h 的帮助下)进行生成,我使用了一个更为自然的编程模型,编写正确有效的代码更为简单。 前面的 GetSystemFontCollection 方法返回一个 FontCollection 类,其中包含一个迭代器,它将延迟提取字体系列对象。 这使得编译器能够有效地实现基于范围的循环。 图 2 提供了完整列表。 与图 1 中的代码比较,它更为清晰,效率更高。

图 2 使用 dx.h 枚举字体

auto factory = CreateFactory();
auto fonts = factory.GetSystemFontCollection();
wchar_t locale[LOCALE_NAME_MAX_LENGTH];
VERIFY(GetUserDefaultLocaleName(locale, _countof(locale)));
for (auto family : fonts)
{
  auto names = family.GetFamilyNames();
  unsigned index;
  if (names.FindLocaleName(locale, index))
  {
    wchar_t name[64];
    names.GetString(index, name);
    wprintf(L"%s\n", name);
  }
}

字体浏览器与 Windows 运行时

DirectWrite 的作用远不只是枚举字体。 我将前面这些内容与 Direct2D 结合起来,创建一个简单的字体浏览器应用程序。 在我 2013 年 8 月的专栏 (https://msdn.microsoft.com/zh-cn/magazine/dn342867.aspx) 中,介绍了如何用标准 C++ 编写基本的 WinRT 应用程序模型。 在我 2013 年 10 月的专栏 (msdn.microsoft.com/magazine/dn451437) 中,介绍了如何通过 DirectX(具体而言是 Direct2D)在此基于 CoreWindow 的应用程序中进行呈现。 现在介绍如何在 DirectWrite 的帮助下扩展代码,从而使用 Direct2D 呈现文本。

这些专栏发表后,Windows 8.1 发布了,它对 DPI 缩放在最新应用程序和桌面应用程序中的处理方式有一些改动。 以后我会详细介绍 DPI,这里暂不讨论这些更改。 我们只重点扩展我在八月提出,在十月扩展过的 SampleWindow 类,以便支持文本呈现并简化字体浏览。

首先,将 DirectWrite Factory2 类添加为成员变量:

DirectWrite::Factory2 m_writeFactory;

在 SampleWindow CreateDeviceIndependentResources 方法内,可以创建 DirectWrite 工厂:

m_writeFactory = DirectWrite::CreateFactory();

在这里我还可以获取系统字体集和用户的默认区域设置,为枚举字体系列做准备:

auto fonts = m_writeFactory.GetSystemFontCollection();
wchar_t locale[LOCALE_NAME_MAX_LENGTH];
VERIFY(GetUserDefaultLocaleName(locale, _countof(locale)));

我将使应用程序在用户按下向上和向下箭头键时循环切换字体。 我不通过 COM 接口不断枚举字体集,而只是将字体系列名称复制到一个标准 set 容器:

set<wstring> m_fonts;

现在,我可以在 CreateDeviceIndependentResources 中使用图 2 中基于范围的 for 循环将名称添加到 set:

m_fonts.insert(name);

set 填充后,使用一个指向 set 开头的迭代器开始应用程序。 将迭代器存储为成员变量:

set<wstring>::iterator m_font;

SampleWindow CreateDeviceIndependentResources 方法初始化迭代器并调用 CreateTextFormat 方法,定义如下:

m_font = begin(m_fonts);
CreateTextFormat();

在 Direct2D 绘制文本前,需要创建一个文本格式对象。 这需要字体系列名称和所需字号。 我允许用户使用向左和向右箭头键更改字号,因此添加一个成员变量来跟踪字号:

float m_size;

Visual C++ 编译器很快会允许我初始化像这样的类内非静态数据成员。 现在,需要在 SampleWindow 的构造函数中将它设置为合理的默认值。 接下来,需要定义 CreateTextFormat 方法。 这只是 DirectWrite 工厂方法的同名包装,但它更新了一个成员变量,Direct2D 可以使用该变量定义要绘制的文本的格式:

TextFormat m_textFormat;

然后,CreateTextFormat 方法从 set 迭代器检索字体系列名称,将其与当前字号组合,创建一个新的文本格式对象:

void CreateTextFormat()
{
  m_textFormat = m_writeFactory.CreateTextFormat(m_font->c_str(),m_size);
}

我已经把它包装起来,因此,除了最初在 CreateDeviceIndependentResources 末尾调用它外,我还可以在每次用户按下一个箭头键更改字体系列或字号时调用它。 这引出了在 WinRT 应用程序模型中如何处理按键的问题。 在桌面应用程序中,这与 WM_KEYDOWN 消息处理有关。 好在 CoreWindow 提供了 KeyDown 事件,它是这一消息的最新等效项。 我从定义 IKeyEventHandler 接口开始,SampleWindow 需要实现该接口:

typedef ITypedEventHandler<CoreWindow *, KeyEventArgs *> IKeyEventHandler;

然后,可以将此接口添加到继承接口的 SampleWindow 列表中,并相应地更新 QueryInterface 实现。 接下来只需提供它的 Invoke 实现:

auto __stdcall Invoke(
  ICoreWindow *,IKeyEventArgs * args) -> HRESULT override
{
  ...
  return S_OK;
}

IKeyEventArgs 接口提供的信息与 LPARAM 和 WPARAM 向 WM_KEYDOWN 消息提供的信息基本相同。 它的 get_VirtualKey 方法对应于后者的 WPARAM,指示按下了哪个非系统键:

VirtualKey key;
HR(args->get_VirtualKey(&key));

与此类似,它的 get_KeyStatus 方法对应于 WM_KEYDOWN 的 LPARAM。 这样可以提供有关按键事件状态的丰富信息:

CorePhysicalKeyStatus status;
HR(args->get_KeyStatus(&status));

为方便用户,我支持在用户按住箭头键时加速,以便更快地调整所呈现字体的字号。 为此,需要另一个成员变量:

unsigned m_accelerate;

然后,可以使用该事件的键状态来确定是将字号更改一个增量还是增加一定的大小:

if (!status.WasKeyDown)
{
  m_accelerate = 1;
}
else
{
  m_accelerate += 2;
  m_accelerate = std::min(20U, m_accelerate);
}

我设置了上限,因此加速不会太过。 现在可以分别处理不同的按键。 首先是向左箭头键,用于缩小字号:

if (VirtualKey_Left == key)
{
  m_size = std::max(1.0f, m_size - m_accelerate);
}

我很小心,不会使字号无效。 然后是向右箭头键,用于增加字号:

else if (VirtualKey_Right == key)
{
  m_size += m_accelerate;
}

接下来,移到上一字体系列,处理向上箭头键:

if (begin(m_fonts) == m_font)
{
  m_font = end(m_fonts);
}
--m_font;

然后,我仔细循环到最后一个字体,看看迭代器是否会回到序列开头。 接下来,移到下一字体系列,处理向下箭头键:

else if (VirtualKey_Down == key)
{
  ++m_font;
  if (end(m_fonts) == m_font)
  {
      m_font = begin(m_fonts);
  }
}

在这里,再一次仔细循环到开头,看看迭代器是否会回到序列结尾。 最后,我可以在事件处理程序末尾调用 CreateTextFormat 方法来重新创建文本格式对象。

剩下的事情是,更新 SampleWindow Draw 方法,用当前文本格式绘制一些文本。 方法如下:

wchar_t const text [] = L"The quick brown fox jumps over the lazy dog";
m_target.DrawText(text, _countof(text) - 1,
  m_textFormat,
  RectF(10.0f, 10.0f, size.Width - 10.0f, size.Height - 10.0f),
  m_brush);

Direct2D 呈现目标的 DrawText 方法直接支持 DirectWrite。 现在 DirectWrite 就可以处理文本布局了,而且呈现速度非常快。 这就是该脚本所执行的所有操作。 图 3 是执行效果。 我可以按向上和向下箭头键遍历字体系列,按向左和向右箭头键调整字号。 Direct2D 根据当前选择自动重新呈现。

The Font Browser
图 3 字体浏览器

彩色字体

Windows 8.1 引入了一个称为彩色字体的新功能,去掉了一些实现彩色字体的次优解决方案。 当然,这都是 DirectWrite 和 Direct2D 促成的。 令人高兴的是,调用 Direct2D DrawText 方法就像使用 D2D1_DRAW_TEXT_OPTIONS_ENABLE_COLOR_­FONT 常量一样简单。 我可以将 SampleWindow 的 Draw 方法更新为使用相应范围的枚举值:

 

m_target.DrawText(text, _countof(text) - 1,
  m_textFormat,
  RectF(10.0f, 10.0f, size.Width - 10.0f, size.Height - 10.0f),
  m_brush);
DrawTextOptions::EnableColorFont);

图 4 也是字体浏览器,这次是一些 Unicode 表情。

Color Fonts
图 4 彩色字体

彩色字体的亮点在于可以自动缩放,而不影响质量。 我可以在字体浏览器应用程序中按向右箭头键,更近地查看细节。 结果如图 5 所示。

Scaled Color Fonts
图 5 放大的彩色字体

通过提供彩色字体、硬件加速文本呈现以及流畅有效的代码,DirectWrite 在 Direct2D 和最新 C++ 的帮助下焕发出生命力。

Kenny Kerr 是加拿大的一名计算机程序员,他是 Pluralsight 的作者,也是 Microsoft MVP。他的博客网址是 kennykerr.ca,您可以通过 Twitter twitter.com/kennykerr 关注他。

衷心感谢以下技术专家对本文的审阅:Worachai Chaoweeraprasit (Microsoft)
Worachai Chaoweeraprasit 是 Direct2D 和 DirectWrite 开发组组长。 他对 2D 矢量图形的速度和质量,以及屏幕文本的可读性异常痴迷。 闲暇时,他喜欢在家陪两位“小大人”嬉戏、玩耍。