3、FreeType库入门:FreeType架构、初始化与加载字体、获取字形(Glyph)信息
说到字体渲染,FreeType 几乎是绕不开的库。我最早接触它是在做一款嵌入式设备时,那时候屏幕分辨率低,字库还得自己从点阵里抠。后来换了高分辨率屏,点阵字根本没法看,才真正开始啃 FreeType。
说白了,FreeType 就是一个开源的字体引擎。它能解析 TrueType、OpenType、Type1 这些格式,把轮廓信息变成像素点。你想想看,一个「A」字,在电脑里存的不是图片,而是一堆贝塞尔曲线和指令。FreeType 就是负责把这些曲线算出来,再填上颜色。
3.1 FreeType 的整体架构
FreeType 的架构其实不复杂。我习惯把它分成三层:
- 底层:文件读取和内存管理。它不直接操作文件系统,而是通过回调函数让你自己提供数据。这在嵌入式里特别有用——字库可能放在 flash、SD 卡甚至网络里。
- 核心层:字体解析和字形渲染。这部分是 FreeType 的心脏。它负责把字体文件里的表(tables)读出来,然后根据字符编码找到对应的字形。
- 上层:API 接口。你调用的 FT_New_Face、FT_Load_Char 这些函数,都是这层暴露出来的。
嗯,这里要注意:FreeType 是模块化的。你可以在编译时裁剪掉不需要的模块,比如只保留 TrueType 支持,去掉 OpenType 的 CFF 解析。我在一个资源紧张的 MCU 项目里就这么干过,省了将近 100KB 的代码空间。
核心概念:FreeType 里最重要的两个对象是 FT_Library 和 FT_Face。前者是引擎实例,后者代表一个字体文件。一个库可以加载多个字体,但每个字体只能有一个活跃的尺寸(size)。
下面这张图是我自己画的,能帮你快速理解 FreeType 的工作流程:
3.2 初始化 FreeType 库
第一步永远是初始化。代码很简单:
#include <ft2build.h>
#include FT_FREETYPE_H
FT_Library library;
FT_Face face;
int error = FT_Init_FreeType(&library);
if (error) {
// 初始化失败,处理错误
return -1;
}
这里有个坑:FT_Init_FreeType 返回 0 表示成功,非零表示失败。我见过有人写成 if (!error),结果调试了半天。FreeType 的错误码是正数,0 才是成功,跟很多 POSIX 函数正好相反。
注意:在嵌入式系统里,FT_Init_FreeType 可能会分配内存。如果你的系统没有 heap,或者 heap 很小,需要提前配置 FreeType 的内存管理。我记得在某个 RTOS 上,默认的 malloc 只有 4KB,加载一个中文字体直接崩了。后来改用静态内存池才解决。
3.3 加载字体文件
初始化之后,就该加载字体了。FreeType 支持两种方式:从文件加载和从内存加载。
| 函数 | 用途 | 适用场景 |
|---|---|---|
FT_New_Face |
从文件路径加载字体 | 文件系统可用,字体放在磁盘上 |
FT_New_Memory_Face |
从内存缓冲区加载字体 | 字体打包在固件里,或从网络下载 |
我个人更推荐 FT_New_Memory_Face,尤其是在嵌入式里。为什么?因为文件系统不一定可靠,而且从内存加载可以省去文件 I/O 的开销。我曾经在一个项目里把整个字库编译进固件,用 FT_New_Memory_Face 加载,启动速度比从 SD 卡读快了 3 倍。
// 从文件加载
error = FT_New_Face(library, "/usr/share/fonts/arial.ttf", 0, &face);
if (error == FT_Err_Unknown_File_Format) {
// 不支持的字体格式
} else if (error) {
// 文件不存在或无法读取
}
// 从内存加载(推荐)
extern const unsigned char font_data[];
extern const size_t font_size;
error = FT_New_Memory_Face(library, font_data, font_size, 0, &face);
if (error) {
// 处理错误
}
第二个参数 0 是 face_index。一个字体文件可能包含多个字体(比如同一个文件里有 Regular、Bold、Italic),用索引来区分。一般传 0 就行。
3.4 设置字符尺寸
加载完字体,得告诉 FreeType 你要多大的字。这里有两个函数:
FT_Set_Pixel_Sizes:直接指定像素宽高。比如 16x16 像素。FT_Set_Char_Size:用物理单位指定,比如 12pt,需要配合 DPI 使用。
我几乎只用 FT_Set_Pixel_Sizes。为什么?因为屏幕就是像素驱动的,你设 16 像素,出来的就是 16 像素高。用 pt 还得算 DPI,麻烦不说,不同屏幕效果还不一样。
// 设置字号为 16 像素
error = FT_Set_Pixel_Sizes(face, 0, 16);
if (error) {
// 字号设置失败
}
第一个参数是宽度,传 0 表示自动根据高度等比缩放。一般传 0 就行。
经验之谈:字号不要设太小。小于 8 像素时,FreeType 的 hinting 算法可能失效,字形会糊成一团。我做过测试,12 像素以上效果最好。如果必须用小字,考虑用点阵字体或者 SDF(有符号距离场)方案。
3.5 加载字形并获取信息
字号设好了,就可以加载具体字符了。核心函数是 FT_Load_Char:
// 加载字符 'A',只加载轮廓,不渲染
error = FT_Load_Char(face, 'A', FT_LOAD_DEFAULT);
if (error) {
// 加载失败
}
// 加载字符 'A',并直接渲染成位图
error = FT_Load_Char(face, 'A', FT_LOAD_RENDER);
if (error) {
// 加载失败
}
FT_LOAD_DEFAULT 只加载轮廓数据,不生成位图。你需要自己调用 FT_Render_Glyph 来渲染。而 FT_LOAD_RENDER 一步到位,直接出位图。我建议在性能敏感的场景用后者,少一次函数调用。
加载完成后,字形信息存在 face->glyph 里。这是一个 FT_GlyphSlot 结构体,里面包含了你需要的一切:
| 成员 | 含义 | 典型值(16px 'A') |
|---|---|---|
bitmap.buffer |
渲染后的像素数据 | 指向灰度位图数组 |
bitmap.width |
位图宽度(像素) | 11 |
bitmap.rows |
位图高度(像素) | 15 |
bitmap_left |
位图左边界相对于基线的偏移 | 1 |
bitmap_top |
位图上边界相对于基线的偏移 | 12 |
advance.x |
下一个字符的 X 方向步进(1/64 像素) | 1024(即 16 像素) |
这里有个容易搞混的地方:advance.x 的单位是 1/64 像素。也就是说,如果 advance.x 是 1024,实际步进是 1024 / 64 = 16 像素。我刚开始做排版时忘了除 64,结果字符全叠在一起了。
// 获取字形位图并绘制
FT_GlyphSlot slot = face->glyph;
// 遍历位图,绘制到 framebuffer
for (int y = 0; y < slot->bitmap.rows; y++) {
for (int x = 0; x < slot->bitmap.width; x++) {
int pixel = slot->bitmap.buffer[y * slot->bitmap.pitch + x];
// 将 pixel 绘制到屏幕的 (x + bitmap_left, y + bitmap_top) 位置
}
}
// 更新光标位置
cursor_x += slot->advance.x >> 6; // 右移 6 位等价于除以 64
关键点:bitmap.pitch 不一定等于 bitmap.width。FreeType 为了内存对齐,每行可能多几个字节。遍历时一定要用 pitch 作为行步长,否则图像会歪斜。我踩过这个坑,调试了一下午才发现是 pitch 的问题。
3.6 清理资源
用完了别忘了释放。顺序很重要:先释放 face,再释放 library。
FT_Done_Face(face);
FT_Done_FreeType(library);
如果你加载了多个 face,每个都要单独释放。library 是全局的,在所有 face 释放后才能释放。