2. FreeType库入门:架构、初始化与加载字体

做嵌入式字体渲染,FreeType 几乎是绕不开的库。我最早接触它是在一个智能手表项目上,那时候屏幕只有 1.3 寸,但客户要求显示多种语言的文字。嗯,折腾了几天后我才发现,FreeType 的架构设计其实非常精巧,搞懂了它,后面的事情就顺了。

2.1 FreeType 的整体架构

说白了,FreeType 是一个字体引擎。它不负责把字画到屏幕上,而是负责把字体文件里的数据解析出来,告诉你每个字长什么样、有多大、基线在哪。你想想看,一个字体文件可能有几千个字形,每个字形还有不同的轮廓信息,这活可不简单。

FreeType 的架构分三层,我画了张图帮你理解:

FreeType 库三层架构 应用层(Application Layer) FT_New_Face / FT_Load_Glyph / FT_Get_Glyph 核心层(Core Layer) 字形管理 / 缩放 / 渲染模式 / 缓存 底层(Base Layer) 文件 I/O / 内存管理 / 字体格式解析(TrueType, OpenType, Type1...) 关键点 应用层只调 API 核心层做计算 底层处理格式差异 层与层之间 通过内部接口通信 你只需要关心 应用层的 API

底层负责跟字体文件打交道。不管是 .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% 的渲染时间。