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 的工作流程:

FreeType 字形渲染流程 FT_Init_FreeType FT_New_Face FT_Set_Pixel_Sizes FT_Load_Char FT_Render_Glyph 注意 每个 FT_Face 只能设置一个 活跃字号。切换字号需要 重新调用 FT_Set_Pixel_Sizes。 提示 FT_Load_Char 默认只加载 轮廓,不渲染。需要加 FT_LOAD_RENDER 标志。

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 释放后才能释放。