2. FreeType库入门:架构、初始化与加载字体
做嵌入式字体渲染,FreeType 几乎是绕不开的库。我最早接触它是在一个智能手表项目上,那时候屏幕只有 1.3 寸,但客户要求显示多种语言的文字。嗯,折腾了几天后我才发现,FreeType 的架构设计其实非常精巧,搞懂了它,后面的事情就顺了。
2.1 FreeType 的整体架构
说白了,FreeType 是一个字体引擎。它不负责把字画到屏幕上,而是负责把字体文件里的数据解析出来,告诉你每个字长什么样、有多大、基线在哪。你想想看,一个字体文件可能有几千个字形,每个字形还有不同的轮廓信息,这活可不简单。
FreeType 的架构分三层,我画了张图帮你理解:
底层负责跟字体文件打交道。不管是 .ttf、.otf 还是 .woff,底层都会把它们解析成统一的内部结构。核心层拿到这些结构后,做缩放、抗锯齿、渲染模式转换。应用层就是咱们写代码时直接调用的那些 API,比如 FT_New_Face、FT_Load_Glyph。
我的经验:刚开始用 FreeType 时,我总想直接操作底层数据,结果代码又乱又容易崩。后来发现,老老实实走应用层 API,性能反而更好。FreeType 内部做了很多优化,别自己瞎折腾。
2.2 初始化 FreeType 库
用 FreeType 的第一步,就是初始化库对象。这个对象是全局的,整个程序生命周期里只需要创建一次。我习惯在系统启动时初始化,然后一直复用。
#include <ft2build.h>
#include FT_FREETYPE_H
FT_Library library;
FT_Error error;
/* 初始化 FreeType 库 */
error = FT_Init_FreeType(&library);
if (error) {
/* 初始化失败,处理错误 */
printf("FreeType 初始化失败,错误码: %d\n", error);
return -1;
}
这里有个坑,我踩过。FT_Init_FreeType 返回 0 表示成功,非零表示失败。但不同版本的 FreeType 错误码含义不一样。我曾经在升级库版本后,程序突然报错,查了半天才发现是错误码变了。建议你初始化后立刻检查返回值,别偷懒。
注意:FT_Library 是线程不安全的。如果你在多线程环境里用,每个线程需要有自己的 FT_Library 实例,或者加锁保护。我在一个多线程渲染项目里吃过这个亏,画面时不时花屏,排查了两天才找到原因。
2.3 加载字体文件
初始化完库,下一步就是加载字体。FreeType 支持两种方式:从文件加载和从内存加载。嵌入式系统里,字体文件经常打包在固件里,所以从内存加载更常见。
| 加载方式 | 函数 | 适用场景 |
|---|---|---|
| 从文件加载 | FT_New_Face |
文件系统可用,字体文件独立存储 |
| 从内存加载 | FT_New_Memory_Face |
字体打包在固件/资源中,无文件系统 |
从文件加载很简单:
FT_Face face;
error = FT_New_Face(library, "/usr/share/fonts/NotoSansSC-Regular.ttf", 0, &face);
if (error) {
printf("加载字体失败,错误码: %d\n", error);
FT_Done_FreeType(library);
return -1;
}
第二个参数是字体文件路径,第三个参数是字体索引。一个 .ttc 文件可能包含多个字体,索引 0 表示第一个。普通 .ttf 文件索引固定为 0。
从内存加载更常用在嵌入式场景:
extern const unsigned char font_data[];
extern const size_t font_size;
FT_Face face;
error = FT_New_Memory_Face(library, font_data, font_size, 0, &face);
if (error) {
printf("从内存加载字体失败\n");
return -1;
}
小技巧:加载字体后,记得检查 face->num_glyphs。如果这个值是 0,说明字体文件里没有字形数据,可能是文件损坏或者格式不支持。我遇到过客户提供的字体文件是空壳,就是靠这个字段发现的。
2.4 设置字体大小
加载完字体,得告诉 FreeType 你要多大的字。这里有两个概念容易混淆:字符大小(character size)和像素大小(pixel size)。
/* 设置字符大小:72 DPI 下 16pt */
FT_Set_Char_Size(face, 0, 16 * 64, 72, 72);
/* 设置像素大小:直接指定 16 像素 */
FT_Set_Pixel_Sizes(face, 0, 16);
我个人更推荐 FT_Set_Pixel_Sizes,尤其是在嵌入式系统里。因为屏幕的物理像素是固定的,用像素大小更直观。字符大小适合在桌面应用里用,跟 DPI 挂钩,能保证不同屏幕上显示效果一致。
注意第二个参数是宽度,第三个是高度。如果宽度传 0,FreeType 会根据高度自动计算宽高比。我一般传 0,省事。
2.5 获取字形度量信息
终于到了核心环节。加载一个字形,然后读出它的度量信息。度量信息包括宽度、高度、基线偏移、左右边距等。这些数据决定了你把字画在屏幕上的哪个位置。
FT_UInt glyph_index;
FT_Error error;
/* 获取字符 'A' 的字形索引 */
glyph_index = FT_Get_Char_Index(face, 'A');
if (glyph_index == 0) {
printf("字形不存在\n");
return;
}
/* 加载字形,FT_LOAD_DEFAULT 是默认模式 */
error = FT_Load_Glyph(face, glyph_index, FT_LOAD_DEFAULT);
if (error) {
printf("加载字形失败\n");
return;
}
/* 获取度量信息 */
FT_GlyphSlot slot = face->glyph;
int width = slot->metrics.width / 64; /* 字形宽度(像素) */
int height = slot->metrics.height / 64; /* 字形高度(像素) */
int horiBearingX = slot->metrics.horiBearingX / 64; /* 水平左边界 */
int horiBearingY = slot->metrics.horiBearingY / 64; /* 垂直上边界 */
int horiAdvance = slot->metrics.horiAdvance / 64; /* 水平步进宽度 */
为什么除以 64?FreeType 内部用 26.6 定点数表示度量,1 个单位 = 1/64 像素。除以 64 就转成像素了。我刚开始用的时候忘了除,画出来的字全挤在一起,还以为是字体文件坏了。
度量信息的含义,我整理了一张表:
| 字段 | 含义 | 典型用途 |
|---|---|---|
| width | 字形实际宽度 | 决定字形位图大小 |
| height | 字形实际高度 | 决定字形位图大小 |
| horiBearingX | 水平方向左边界偏移 | 绘制时水平起始位置 |
| horiBearingY | 垂直方向上边界偏移(从基线算起) | 绘制时垂直起始位置 |
| horiAdvance | 当前字形结束后,下一个字形的起始偏移 | 计算行内字符间距 |
基线的概念特别重要。你看英文书写时,字母底部有一条对齐线,那就是基线。horiBearingY 是从基线往上算的,所以绘制时你要用 baseline_y - horiBearingY 作为位图的起始 y 坐标。
避坑指南:我曾经在绘制中文字符时,直接用 horiBearingY 作为 y 坐标,结果字都跑到基线上面去了。后来才发现,中文字符的 horiBearingY 比英文字母大很多,因为中文字符的顶部更高。正确的做法是:draw_y = baseline_y - horiBearingY。
2.6 获取全局字体度量
除了单个字形的度量,字体本身也有一些全局度量。比如整行文字的高度、最大宽度、下划线位置等。这些信息存在 face->size->metrics 里。
int line_height = face->size->metrics.height / 64; /* 行高 */
int ascender = face->size->metrics.ascender / 64; /* 上升高度 */
int descender = face->size->metrics.descender / 64; /* 下降高度(负值) */
int max_advance = face->size->metrics.max_advance / 64; /* 最大步进宽度 */
行高用来计算多行文本的行间距。ascender 和 descender 加起来就是字体的总高度。注意 descender 是负值,比如 -4 表示基线下方 4 像素。
我一般用 ascender - descender 作为行高,这样每行文字之间不会重叠。当然,你也可以在行高上加一些 padding,让文字更透气。
2.7 清理资源
用完 FreeType 后,记得释放资源。顺序跟创建时相反:先释放 face,再释放 library。
FT_Done_Face(face);
FT_Done_FreeType(library);
如果你在程序里反复加载和释放字体,注意检查内存泄漏。FreeType 内部会缓存一些数据,频繁创建销毁 FT_Library 会导致内存碎片。我建议程序启动时初始化一次,退出时释放一次,中间不要反复创建。
个人习惯:我会在程序初始化时把所有需要用到的字体都加载好,存成一个字体表。渲染时直接查表,避免运行时反复加载字体文件。在 STM32 上实测,这样能省下大约 30% 的渲染时间。