"工欲善其事,必先利其器"——看书要舒服,字体这套器皿得先磨利。内置的字放 Flash 直读、TF 卡的字现用现取、字号字重按需切换,每一件都藏着鲁班式的匠心。
核心方法论:工欲善其事,必先利其器——先把"器"的尺寸、材料、用法量清楚再动手
「鲁班造物前有个规矩:先量料,再下锯。字体这件事也一样——一个汉字字形多大、内存放不放得下、Flash 怎么读、TF 卡怎么扫,都要先量清楚。这一章我们不吹'字体渲染'多厉害,只讲三件器:FreeType 这把斧子、内置字库这块料、动态加载这条流水线。量清楚了,器就顺手。」
先把术语铺平。FreeType 是一个开源的字体渲染引擎——它读取 TTF / OTF 字体文件,把"字符码点"转换成屏幕上的字形位图。它的价值在于通用:同一个函数接口,既能画拉丁字母,也能画汉字。上一季已经讲过"字号、字重、字形缓存"这些概念,这里只回顾和移植最相关的部分,把重点放在一个移植版自己写的、上游没有的模块上——src/ft_dfs_bridge.c。
FreeType 有两种"打开字体"的方式。一种是 FT_New_Memory_Face:字体数据已经整个躺在内存里(内置字库走这条,见 8.2);另一种是 FT_New_Face:FreeType 通过它自己的文件系统回调去读磁盘,每次需要数据就调用 ft_fread / ft_fseek 到文件特定偏移取一段。TF 卡上的外部字体走的就是后一条路(8.3)。关键就藏在 ft_fread 里:中文字体动辄十几兆,加载时 FreeType 会在 loca、glyf、hmtx、cmap 四张表之间频繁跳来跳去取数。如果只有一个单缓冲,每次跳转都要重新读盘——SD 卡在 RT-Thread 的 DFS(设备文件系统)上按块读取,LBA 寻址再快也扛不住这种"抖动"。这就像用一把窄锯来回锯大木料,锯半天木料没动。
ft_dfs_bridge.c 的解法是多槽 LRU 读缓冲。它维护一个 ft_file_t 结构:一个 FT_BUF_LO_SIZE(64 KiB)的低区预读缓冲(专门覆盖 FT_New_Face 读文件头部那段时间的访问),外加 FT_SLOT_COUNT(16)个 4 KiB 的槽位。每次 ft_fread 先查低区缓冲,命中就直接复制;没命中再查 16 个槽(按 start 偏移精确匹配);再没命中,就用 slot_find_oldest 找出最老的一个槽把它换掉,并 slot_fill 读入新数据。字形的局部性很强——同一个表的相邻数据经常连续读,所以槽位命中率很高,读盘次数大幅下降。另一个提速点是 ft_fopen 里的一行:dfs_file_enable_fast_seek(dfs_fp, 1),它通过 fd_get / fd_put 拿到内核文件句柄,给 FatFs 打开簇链映射(CLMT),让 lseek 从"逐簇跳"变成 O(1) 的查表——随机访问字体表的代价从读盘次数降下来,换成一次内存查表。
/* src/ft_dfs_bridge.c:读缓冲的尺寸与槽位定义 */
#define FT_BUF_LO_SIZE (64*1024) /* 低区预读缓冲:覆盖 FT_New_Face 头部访问 */
#define FT_SLOT_SIZE (4*1024) /* 单个槽位大小 */
#define FT_SLOT_COUNT 16 /* 槽位个数:多槽 LRU */
typedef struct FtBufSlot {
uint32_t start; /* 本槽在文件中的起始偏移 */
uint8_t valid; /* 本槽是否有效 */
uint32_t age; /* 最近使用计数(LRU 依据) */
uint8_t data[FT_SLOT_SIZE];
} FtBufSlot;
typedef struct {
int fd; /* 文件描述符 */
uint32_t file_size;
uint32_t file_pos; /* 当前逻辑读位置 */
uint8_t buf_lo[FT_BUF_LO_SIZE]; /* 低区预读缓冲 */
bool buf_lo_valid;
FtBufSlot slots[FT_SLOT_COUNT]; /* 多槽 LRU 缓冲 */
uint32_t age_counter;
} ft_file_t;
/* src/ft_dfs_bridge.c:打开文件时开启 fast_seek + 预读文件头 */
void *ft_fopen(const char *name, int flags)
{
/* ... */
ft_file_t *ftf = malloc(sizeof(ft_file_t));
ftf->fd = open(name, O_RDONLY);
/* ... */
struct dfs_fd *dfs_fp = fd_get(ftf->fd);
dfs_file_enable_fast_seek(dfs_fp, 1); // FatFs CLMT:lseek O(1)
fd_put(dfs_fp);
/* 预读文件头到低区缓冲 ... */
return (void *)ftf;
}
/* ft_fread 优先级:① 低区缓冲命中 → ② 多槽 LRU 命中 → ③ 淘汰最老槽 slot_fill */
int ft_fread(void *buf, int size, int count, void *f)
{
/* ① 命中低区缓冲:直接复制,免读盘 */
/* ② 命中某个槽:更新 age 后复制 */
/* ③ 未命中:slot_find_oldest() 选最老槽,slot_fill() 读入 */
}
| 缓冲 | 大小 | 用途 |
|---|---|---|
| 低区预读缓冲 | 64 KiB | FT_New_Face 读文件头的连续访问 |
| 多槽 LRU | 16 × 4 KiB | loca / glyf / hmtx / cmap 四表间的随机跳读 |
量器:大字体文件加载慢,根因不是"读盘慢",而是"跳读多"——单缓冲每跳一次读一次盘。多槽 LRU 换的是命中率:让"经常一起读的表"留在槽里,把读盘次数换成内存命中。下次遇到"从文件读数据慢",先问:缓冲够不够、命中率高不高?
内置字库选了 DroidSansFallback——一款来自 Android 开源项目的思源黑体降级字体,覆盖简体中文常用字,以 Apache License 2.0 开源协议发布(Copyright 2005-2008 The Android Open Source Project),约 3.9 MiB(font/DroidSansFallback.ttf)。选它当"默认字体"是因为:一,中文覆盖够用;二,开源可分发;三,黑体(无衬线)在墨水屏上笔画均匀、灰度友好,是电子书默认字体的稳妥选择。第 1 章说过它是编译时内置的,这一节把"编译时内置"五个字落到可验证的构建与代码上。
内置靠的是 SiFli SDK 的 SCons 构建脚本。font/SConscript 里有一行 Env.ConvertFont(font_file, 'epub_ttf_data')——这个自定义构建动作把 DroidSansFallback.ttf 整个转成一个 C 语言数组符号,名为 epub_ttf_data,同时在链接脚本的指定段放一份 epub_ttf_data_size 记录大小。于是源码里就能直接引用:extern const unsigned char epub_ttf_data[]; extern const int epub_ttf_data_size;(见 main.cpp)。这些数据最终放在 Flash 的 XIP 段里。
这里要解释一个关键术语。XIP(Execute In Place,原地执行):程序或数据放在 Flash 里,CPU 通过内存映射直接访问,而不必先把内容复制到 RAM 再读。SF32 的 Flash 支持 XIP,所以 epub_ttf_data[] 这 3.9 MiB 的字形数据"住在 Flash、直接可读",一分钱 RAM 都不占。再看 font_ft.c:epd_font_ft_init_builtin 调 epd_font_ft_init(epub_ttf_data, epub_ttf_data_size, size),也就是走 FT_New_Memory_Face——"内存面"的名字会让人误会,其实这里的"内存"指的就是这块可直接寻址的 XIP 数据,FreeType 只管按指针读,不需要你把它搬到普通 RAM。启动日志里 main.cpp 会打印 TTF data at %p, size=%d 和 PSRAM free before/after font init,用 7.3 的 heap_free_size() 就能亲眼看到:初始化字体后,PSRAM 只少了结构体和缓存占用的那点,字形本体始终在 Flash 里躺着。
/* font/SConscript:把 TTF 编译成内置数组符号 */
# 取当前目录下的字体文件,转成 epub_ttf_data / epub_ttf_data_size
font_file = os.path.join(cwd, 'DroidSansFallback.ttf')
Env.ConvertFont(font_file, 'epub_ttf_data')
/* src/main.cpp:引用内置字体符号 */
extern const unsigned char epub_ttf_data[];
extern const int epub_ttf_data_size;
/* lib/epdiy/font_ft.c:内置字体走 FT_New_Memory_Face(数据直接可寻址) */
void epd_font_ft_init_builtin(uint32_t pixel_size)
{
epd_font_ft_init(epub_ttf_data, epub_ttf_data_size, pixel_size);
}
static void epd_font_ft_init(const unsigned char *ttf_data, size_t size, uint32_t pixel_size)
{
FT_Library library;
FT_New_Library(&library); // 初始化 FreeType 库
/* ... */
FT_New_Memory_Face(library, ttf_data, size, 0, &face); // 从 Flash XIP 数据建 Face
FT_Set_Pixel_Sizes(face, 0, pixel_size); // 设置字号
/* ... */
}
| 环节 | 做法 | 代价 |
|---|---|---|
| 内置字库存储 | Flash XIP,直接寻址 | 占 Flash 约 3.9 MiB,RAM 0 |
| Face 打开方式 | FT_New_Memory_Face | 无读盘、无缓冲占用 |
| 字形数据来源 | epub_ttf_data[] 段内直读 | 一次 XIP 读 ≈ 一次内存读 |
料要放在最划算的地方。3.9 MiB 的汉字库放 RAM 是奢侈,放 Flash 读一次顶多慢一点、但省出一大块可编程内存。XIP 就是"用一次读的慢,换整块 RAM 的省"——记住这个交换,你就能在别处复用它。
内置字库能打底,但不能满足所有人——有人想用思源宋体看正文,有人想换更粗的字重。于是移植版加了一条动态字体加载流水线:把字体文件放到 TF 卡的 /fonts 目录,开机自动扫描,用户即可在设置里选择。src/font_manager.c 是这个流水线的管家,几个宏写死了规模:FONT_MAX_COUNT(最多 64 个外部字体)、FONT_NAME_MAX(32)、FONT_PATH_MAX(64)。它维护一张 g_font_list[FONT_MAX_COUNT],表项 FontEntry 有名字、路径、是否内置三个字段;第 0 项永远预留给内置的 "Default" 字体,用户怎么换都有一条兜底。
扫描在 font_manager_init(font_dir):打开 /fonts,用 opendir / readdir 遍历目录,对每个条目调用 is_ttf_file()——它认 .ttf / .TTF / .otf / .OTF 四种后缀——匹配就把文件名和拼好的完整路径登记进 g_font_list。注意后缀大小写都要认:TF 卡是用户手动格式化,谁也不知道他存的是 songti.ttf 还是 SongTi.TTF。扫描完,主界面和设置页就能用 font_manager_get_count()、font_manager_get_name(index) 列出全部字体;font_manager_select(index, pixel_size) 负责真正切换。
切换的细节值得逐行看。font_manager_select 先做清理:cache_clear() 清掉旧字形的位图缓存(第 8 章开头说过 3 MiB 的 4bpp 字形缓存),再 deinit() 释放旧 Face。然后分两条路:g_font_list[i].is_builtin 为真,走 epd_font_ft_init_builtin(pixel_size)(Flash XIP,见 8.2);为假,走 epd_font_ft_init_from_file(path, pixel_size),FreeType 从 TF 卡文件直读,也就是 8.1 的 ft_dfs_bridge 发挥作用的地方——外部字体的数据不占 PSRAM,随用随读。一旦初始化失败(卡没插、文件损坏、格式不支持),代码回退到内置 Default 并置 g_current_font=0,返回 -4 让上层知道"没换成"。最后 main.cpp 的 reading_settings_load 会读 /settings.cfg 里的 font_name=...:如果设置的名字在列表里找不到(比如这次没插 TF 卡),就静默回退 Default。这一整套"找不到就兜底"的兜底设计,正是嵌入式 UI 该有的健壮性。
/* src/font_manager.c:字体表与扫描(节选) */
#define FONT_MAX_COUNT 64
#define FONT_NAME_MAX 32
#define FONT_PATH_MAX 64
typedef struct {
char name[FONT_NAME_MAX]; /* 字体名(文件名去后缀) */
char path[FONT_PATH_MAX]; /* 完整路径,如 /fonts/songti.ttf */
int is_builtin; /* 1=内置 Default;0=TF 卡外部字体 */
} FontEntry;
static FontEntry g_font_list[FONT_MAX_COUNT]; /* 第 0 项恒为内置 Default */
int font_manager_init(const char *font_dir) // 调用方传 "/fonts"
{
/* 登记内置 Default:is_builtin=1,路径为空 */
/* opendir / readdir 遍历,is_ttf_file() 匹配后缀则登记 */
}
/* src/font_manager.c:切换字体(节选) */
int font_manager_select(int index, uint32_t pixel_size)
{
cache_clear(); // 清空旧字形缓存
deinit(); // 释放旧 Face
if (g_font_list[index].is_builtin) {
// 内置字体:Flash XIP,通过 FT_New_Memory_Face
epd_font_ft_init_builtin(pixel_size);
} else {
// TF 卡字体:FreeType 直接从文件读取,不占 PSRAM
if (epd_font_ft_init_from_file(g_font_list[index].path, pixel_size) != 0) {
// 失败:回退内置 Default
g_current_font = 0;
epd_font_ft_init_builtin(pixel_size);
return -4;
}
}
g_current_font = index;
return 0;
}
| 配置 | 值 | 含义 |
|---|---|---|
FONT_MAX_COUNT | 64 | 最多登记 64 个字体(含内置 Default) |
/fonts | 目录 | 外部字体放置位置,开机自动扫描 |
| 后缀识别 | .ttf/.TTF/.otf/.OTF | is_ttf_file() 大小写通吃 |
| 外部字体内存 | 不占 PSRAM | ft_dfs_bridge 随用随读 |
造流水线要留退路。扫描要兜底(第 0 项内置永远在)、切换要兜底(失败回退 Default 返回 -4)、设置要兜底(找不到名字回退 Default)。三条兜底,用户怎么折腾都不会白屏。这是"器"耐用的关键。
字号和字重是阅读体验的两根旋钮。先量字号。reading_settings.cpp 把字号定义成 7 档枚举:FONT_SIZES[] = {24, 28, 32, 36, 40, 44, 48},单位是像素——即把字形渲染到多高的格子。字号 32 像素时字占满 32px 高的格,行距由 g_line_spacing(默认 1.2)乘出来;第 9 章会讲行距和边距怎么配合。而 epd_font_ft_set_size 最终调 FT_Set_Pixel_Sizes(face, 0, pixel_size),FreeType 会据此决定每级字形位图的大小。
字重(font weight)指的是笔画粗细——同一款字体有不同"粗细的变体",常见分级是 400(正常)、500(中)、700(粗)。reading_settings.cpp 的 BOLD_LEVELS[] = {0, 1, 2} 对应三种状态,界面文案分别是 正常 / 中粗 / 粗体。真正的实现落在 font_ft.c 的两条腿:如果字体是可变字体(variable font,一个文件里能平滑表达多个字重的字体格式,字重是一个可调轴向),就用 detect_variable_font 检测、用 apply_variable_weight 把 wght 轴设到 BOLD_WEIGHT_MAP[]={400, 600, 700} 对应的值(FreeType 里字重轴用 FT_MAKE_TAG('w','g','h','t') 标识);如果不是可变字体,就走 EMBOLDEN_STRENGTH[]={0, 1, 2}——用 FT_Outline_Embolden 把字形轮廓向外加粗。加粗会损失笔画细节、让字变糊,所以移植版把强度压在 3 档以内。epd_font_ft_set_bold(level) 会先 clamp 到 0–2,防止越界。
最后回答一个很多读者会问的问题:为什么默认不启用粗体 / 斜体中文字库?README 写得很直白——"为控制资源占用,默认未启用"。原因要分两层。其一,汉字没有"斜体"概念,粗体又常靠描边加粗凑数,专门的粗体字库要么没有、要么每个字都要另存一份(Flash 吃不消);其二,一套中文字库动辄数兆,粗体再多备一套就翻倍——在 Flash 和 PSRAM 都金贵的嵌入式里不划算。所以移植版的取舍是:正文用一套内置黑体(is_builtin 的 Default),粗体效果靠 FreeType 运行时加粗(可变字重或 FT_Outline_Embolden),斜体干脆不提供。省下来的 Flash,留给用户自己往 /fonts 塞他喜欢的字体——这比内置十套"可能用不上"的字库聪明得多。
/* src/reading_settings.cpp:字号档位与字重档位 */
static const int FONT_SIZES[] = {24, 28, 32, 36, 40, 44, 48}; // 像素字号
static const int BOLD_LEVELS[] = {0, 1, 2}; // 正常 / 中粗 / 粗体
/* lib/epdiy/font_ft.c:字重映射与加粗强度 */
static const uint32_t BOLD_WEIGHT_MAP[] = {400, 600, 700}; // 可变字体 wght 轴目标值
static const int EMBOLDEN_STRENGTH[] = {0, 1, 2}; // 非可变字体轮廓加粗强度
// 可变字体:把 wght 轴设为对应字重
// FT_MAKE_TAG('w','g','h','t') 是 FreeType 里字重轴的标签
void apply_variable_weight(FT_Face face, int level)
{
FT_MM_Var *mm;
FT_Get_MM_Var(face, &mm);
/* 找到 wght 轴,set_var_design 设为 BOLD_WEIGHT_MAP[level] ... */
}
// 非可变字体:用 FT_Outline_Embolden 把轮廓加粗
// 调用前:FT_Load_Glyph → 判断是否有 outline → FT_Outline_Embolden(&outline, strength)
/* src/reading_settings.cpp:字号档位选择(节选) */
switch (item) {
case SETTING_FONT_SIZE:
/* 用 FONT_SIZES[index] 取实际像素值 */
epd_font_ft_set_size(FONT_SIZES[setting_value]);
break;
case SETTING_BOLD:
epd_font_ft_set_bold(BOLD_LEVELS[setting_value]);
break;
/* ... */
}
| 档位 | 字号(px) | 字重 | 实现方式 |
|---|---|---|---|
| 字号 | 24 / 28 / 32 / 36 / 40 / 44 / 48 | — | FT_Set_Pixel_Sizes |
| 正常 | — | 0(weight 400 / 强度 0) | 不处理 |
| 中粗 | — | 1(weight 600 / 强度 1) | 可变字重 或 FT_Outline_Embolden |
| 粗体 | — | 2(weight 700 / 强度 2) | 同上,更重 |
量清楚再下锯:字重有两套实现(可变字体走 wght 轴、普通字体走轮廓加粗),是因为"同一份数据"在不同字库上长得不一样。先探测字体是不是可变字体(detect_variable_font),再选对应路子——多分支不是复杂度,是对"料"的尊重。
FT_Outline_Embolden 加粗是"伪粗体":它在轮廓外围再加一圈,笔画确实变粗,但横竖撇捺的细节会被撑开、观感偏糊,小字号尤其明显。所以移植版只提供 0–2 三档、默认 0。真要漂亮的中文粗体,最稳的办法还是用户往 /fonts 放一套真正的粗体字库——器在手,材料可以自己换。
把这一章的"器"收进工具箱:器是 FreeType + ft_dfs_bridge.c,负责把字体文件变成字形位图,大字体靠多槽 LRU 缓冲扛住随机跳读;料是内置 DroidSansFallback,3.9 MiB 藏在 Flash XIP 里、开机即用,不占 RAM;流水线是 font_manager.c + /fonts,扫描 TF 卡、最多 64 个外部字体、切换失败回退 Default;旋钮是字号 7 档 + 字重 3 档,字号走 FT_Set_Pixel_Sizes,字重走可变字重或轮廓加粗。四件套各司其职,串起来就是完整的中文字体系统。
收尾前再看一眼字形缓存,因为它和阅读体验直接挂钩。font_ft.c 里维护一个 4bpp(4 位/像素,即每像素 16 级灰度)字形位图缓存,上限 CACHE_MAX_MEMORY(3 MiB),LRU 淘汰——渲染过的字先进缓存,翻页时命中就免去重复光栅化。两档预热量线值得记住:epd_font_ft_preheat(text) 是阻塞式预热(打开书时把整页字形一次备好);epd_font_ft_preheat_async 则起一个名为 "ft_pre" 的后台 RT-Thread 线程(栈 16384 字节、优先级 25,每 50 个字符主动让出一次 CPU),在后台慢慢把常用字烘进去——这正是第 7 章那把页面互斥锁存在的原因:预热量线和排版解析并行,共享的页面数组必须上锁。缓存命中率、内存占用可以分别用 epd_font_ft_cache_count()、epd_font_ft_cache_memory() 查出来,这两只探针就是第 9 章调行距、第 10 章调渲染性能时你最趁手的量具。
最后对照 README 的"当前功能"核实一遍:中文 / 英文显示 ✓;动态字体加载(TF 卡 /fonts,最多 64 个)✓;内置 Default 为编译时内置的 FreeType 字体(DroidSansFallback,Flash XIP)✓;外部字体由 FreeType 直接从 TF 卡文件读取 ✓;默认未启用粗体 / 斜体中文字库 ✓。字体的每一件"器"都有出处、可验证——这就是鲁班完工的标准:器顺手、料到位、流水线不打结。
/* lib/epdiy/font_ft.c:字形位图缓存与预热量线(节选) */
#define CACHE_HASH_SIZE 256
#define CACHE_MAX_MEMORY (3*1024*1024) // 3 MiB 上限,LRU 淘汰
/* 预热量线:后台逐字光栅化填缓存(真实代码节选) */
static void preheat_thread_entry(void *param)
{
(void)param;
const uint8_t *p = (const uint8_t *)g_preheat_text; // 文本提前拷入全局
uint32_t cp; int total = 0;
while ((cp = next_cp(&p)) != 0) {
if (g_preheat_stop) break; // 可随时停止
rasterize_glyph(cp); // 光栅化一个字符入缓存
if (++total % 50 == 0)
rt_thread_delay(1); // 每 50 字符让出 CPU
}
}
// 线程创建:rt_thread_create("ft_pre", preheat_thread_entry, RT_NULL, 16384, 25, 10)
# 字体系统四件套一览
器 :FreeType + ft_dfs_bridge.c(多槽 LRU 读缓冲,大字体随机跳读提速)
料 :内置 DroidSansFallback(3.9 MiB,Flash XIP,FT_New_Memory_Face)
流水线 :font_manager.c + /fonts(扫描 TF 卡,≤64 外部字体,失败回退 Default)
旋钮 :字号 7 档(24-48px)+ 字重 3 档(正常/中粗/粗体)
缓存 :4bpp 字形位图,3 MiB 上限,LRU 淘汰 + 阻塞/异步预热量线
| 能力 | 实现位置 | 一句话原理 |
|---|---|---|
| 中英文字形渲染 | font_ft.c | FreeType 把码点光栅化成 4bpp 位图 |
| 大字体文件读取 | ft_dfs_bridge.c | 64 KiB 低区 + 16 槽 LRU,少读盘 |
| 内置字体 | font/SConscript + XIP | 编译期转数组,Flash 直读不占 RAM |
| 外部字体 | font_manager.c + /fonts | 扫描登记,随用随读,失败回退 |
| 翻页流畅 | 字形缓存 + 预热量线 | 命中缓存免重光栅化,后台预热 |
完工自查就两问:器能不能独立验证(缓存有 cache_count/memory 探针)、料放在最划算的地方没有(内置 XIP、外部即读)。两问都答上,这台字体的"机器"才算打磨完工。
什么是 XIP?内置的 DroidSansFallback 为什么不占 RAM?它 3.9 MiB 的数据最终放在哪里、如何被 FreeType 读取?
XIP 是原地执行——数据放 Flash,CPU 通过内存映射直接访问;内置字体靠 SConscript 的 ConvertFont 转成 epub_ttf_data 数组,走 FT_New_Memory_Face。
XIP(Execute In Place,原地执行)指程序或数据放在 Flash 里、CPU 通过内存映射直接访问,无需先拷进 RAM。DroidSansFallback 经 font/SConscript 的 Env.ConvertFont(font_file, 'epub_ttf_data') 编译成数组符号 epub_ttf_data(大小 epub_ttf_data_size),放在 Flash 的 XIP 段;font_ft.c 用 FT_New_Memory_Face 直接拿这 3.9 MiB 数据建 Face,字形本体始终在 Flash,RAM 只花结构体和缓存的份。
为什么大字体文件加载会"频繁跳读"?ft_dfs_bridge.c 用了哪两级缓冲,各自解决什么?dfs_file_enable_fast_seek 起什么作用?
FreeType 在 loca/glyf/hmtx/cmap 四表间跳读;低区 64 KiB 覆盖文件头,16 槽 LRU 覆盖四表随机跳;fast_seek 打开 FatFs CLMT 让 lseek 变 O(1)。
加载时 FreeType 会在 loca、glyf、hmtx、cmap 四张表间来回取数,单缓冲每跳一次读一次盘。ft_dfs_bridge.c 用两级缓冲:64 KiB 低区预读缓冲专门覆盖 FT_New_Face 读文件头的连续访问;16 个 4 KiB 槽组成多槽 LRU,覆盖四表间的随机跳读(命中就不读盘,miss 才 slot_find_oldest 淘汰最老槽 slot_fill)。dfs_file_enable_fast_seek(dfs_fp, 1) 给 FatFs 打开簇链映射(CLMT),把 lseek 从逐簇跳变成 O(1) 查表。
font_manager_init 如何扫描 /fonts?切换字体时 font_manager_select 做了哪些事?外部字体加载失败会怎样?
opendir/readdir 遍历 + is_ttf_file 认四类后缀;select 先 cache_clear 再 deinit,内置走 FT_New_Memory_Face、外部走 init_from_file;失败回退 Default 返回 -4。
init 用 opendir/readdir 遍历 /fonts,is_ttf_file() 认 .ttf/.TTF/.otf/.OTF 后缀,把名字和路径登记进 g_font_list(第 0 项恒为内置 Default)。select 先 cache_clear() 清旧字形缓存、deinit() 释放旧 Face;内置走 epd_font_ft_init_builtin(Flash XIP),外部走 epd_font_ft_init_from_file(TF 卡直读,不占 PSRAM)。外部加载失败则回退内置 Default、置 g_current_font=0、返回 -4,用户不会白屏。
字重为什么默认不启用粗体 / 斜体中文字库?可变字体与普通字体的粗体实现有何不同?"伪粗体"有什么代价?
资源考虑 + 中文字库体积;可变字体用 BOLD_WEIGHT_MAP 走 wght 轴,普通字体用 EMBOLDEN_STRENGTH 走 FT_Outline_Embolden;加粗会撑开笔画细节。
README 明说"为控制资源占用,默认未启用":一套中文字库动辄数兆,粗体再备一套 Flash 吃不消,且汉字无斜体概念,故只内置一套黑体,粗体靠运行时加粗。可变字体(一个文件内含多个字重)用 apply_variable_weight 把 wght 轴设到 BOLD_WEIGHT_MAP[]={400,600,700};普通字体用 EMBOLDEN_STRENGTH[]={0,1,2} 调 FT_Outline_Embolden 把轮廓向外加粗。"伪粗体"的代价是笔画被撑开、细节变糊,小字号更明显,所以强度只压到 3 档且默认 0。