老木匠讲究"工欲善其事,必先利其器"。这把"器"就是四套只有黑白两色、塞进 Flash 的字库——它们决定了翻页到底能有多快。
核心方法论:工欲善其事,必先利其器
「鲁班造梯子之前,先得磨斧子。这块屏幕能不能"像翻纸质书一样快",有一半的功劳不在渲染代码,而在那把看不见的"斧子"——字体。一个 18 磅的拉丁字母,字形只有几十像素;可要是贪心把整本中文字库塞进去,光是常用字就要好几 MB,比这设备的整块 Flash 还大。所以字体生成这门手艺,核心就两个字:取舍。只留四种风格、只留拉丁字符、只留黑白两色,最后再把它们压进几万字节——先把"器"磨利,后面雕什么都顺手。」
普通桌面系统要显示文字,直接加载系统的字库即可——说人话,字库就是字体数据的集合,一套字体(一种字形样式,类似宋体/黑体之分)由几千个字形(glyph,单个字符对应的图形,比如字母 A 的样子)组成;几十 MB 的 TTF,对它不过是九牛一毛。但在这台设备上,字体是要编译进固件、常驻 Flash 的资源。README 的开头就把作者的取舍写得很坦白:"I've only included 4 font styles - regular, bold, italic and bold-italic. I've also only generated glyphs for Latin characters and punctuation."——只做四种风格(常规 / 粗体 / 斜体 / 粗斜体),而且只生成拉丁字符与标点的字形。
这四个"瘦身"约束分别回答了一个现实问题。四种风格,是因为 EPUB 的排版里最多只出现 <b> 粗体和 <i> 斜体这两种强调(HTML 解析器也只认这两个标签),作者据此给每种字形准备一套 get_font(bold, italic) 就能覆盖的组合;只留拉丁字符,是因为书源(古登堡计划)以英文书为主;只做黑白两色,则是为了让屏幕走快速刷新通道(14.3 详述)。每一刀都砍在"不常用的部分"上,换来的是"常用的部分更小、更快"。
落地到仓库就是 lib/Fonts/ 下四个头文件:regular_font.h、bold_font.h、italic_font.h、bold_italic_font.h。板级代码(src/boards/Epdiy.cpp、M5Paper.cpp)直接 #include <regular_font.h> 等四个头文件,把四个 EpdFont 结构体指针连同忙碌图标一起传给渲染器构造函数。于是字体数据成为固件的一部分,随 Flash 一起被芯片直接映射执行(XIP),读取不占 RAM。四个文件里的压缩字形数据分别约 56 KB、58 KB、66 KB、68 KB——加起来才约 242 KB。
/* src/boards/Epdiy.cpp:四套字体编译进固件,交给渲染器 */
#include <regular_font.h>
#include <bold_font.h>
#include <italic_font.h>
#include <bold_italic_font.h>
#include <hourglass.h>
...
return new EpdiyRenderer(
®ular_font, &bold_font, &italic_font, &bold_italic_font,
hourglass_data, hourglass_width, hourglass_height);
| 字体文件 | 压缩字形数据 | 对应风格 |
|---|---|---|
regular_font.h | 56,399 字节 | 常规(get_font(false,false)) |
bold_font.h | 57,819 字节 | 粗体(get_font(true,false)) |
italic_font.h | 65,724 字节 | 斜体(get_font(false,true)) |
bold_italic_font.h | 67,883 字节 | 粗斜体(get_font(true,true)) |
把"字体"当成资源预算而非"随便装个库",是嵌入式 UI 的第一课。这里真正的设计是:风格数量被 HTML 能力(只有 b/i 两个强调标签)锁死,字符集被书源(英文)锁死,颜色被刷新模式(黑白)锁死——三个约束叠加,字体量自然收敛到"几十 KB 一个文件"。先问"我到底需要显示什么",再决定生成什么,而不是反过来。
这四个头文件不是人写的,是脚本生成的。README 的 "Fonts" 一节说:"this is a slightly modified script from the epdiy repository that lets you output the font data in only two colors which lets us update the screen considerably faster."——scripts/generate_fonts.sh 是从 EPDiy 官方脚本改来的,关键改动是"只输出两色的字体数据"。看这个脚本:它先用 python3 -m venv venv 建虚拟环境、pip install freetype-py 装字体引擎,再用 curl 从 Google Fonts 拉下 Source Sans Pro 一族四个 TTF;同一时刻还拉来 Open Sans 一族四个 TTF,作为后备字体。然后调用 fontconvert.py 生成四个头文件。
# scripts/generate_fonts.sh:拉字体 → 生成四套字库头文件
pip install freetype-py
curl https://raw.githubusercontent.com/google/fonts/main/ofl/sourcesanspro/SourceSansPro-Regular.ttf \
-o SourceSansPro-Regular.ttf
curl .../SourceSansPro-Bold.ttf -o SourceSansPro-Bold.ttf
curl .../SourceSansPro-Italic.ttf -o SourceSansPro-Italic.ttf
curl .../SourceSansPro-BoldItalic.ttf -o SourceSansPro-BoldItalic.ttf
# (同样方式拉来 OpenSans 四个 TTF 作为后备)
python3 fontconvert.py regular_font 18 SourceSansPro-Regular.ttf OpenSans-Regular.ttf \
--two-color --compress > ../lib/Fonts/regular_font.h
python3 fontconvert.py bold_font 18 SourceSansPro-Bold.ttf OpenSans-Bold.ttf \
--two-color --compress > ../lib/Fonts/bold_font.h
python3 fontconvert.py italic_font 18 SourceSansPro-Italic.ttf OpenSans-Italic.ttf \
--two-color --compress > ../lib/Fonts/italic_font.h
python3 fontconvert.py bold_italic_font 18 SourceSansPro-BoldItalic.ttf OpenSans-BoldItalic.ttf \
--two-color --compress > ../lib/Fonts/bold_italic_font.h
fontconvert.py 是真正干活的人,它做四件事。第一,确定字符区间:脚本里内置一份 intervals 列表,覆盖 ASCII 可打印字符(0x20–0x7E)、Latin-1 补充区(0xA0–0x17E)、西里尔字母(0x400–0x4FF),以及弯引号、破折号、省略号等常用标点;还提供 --additional-intervals 参数(可重复)让你按 "min,max" 追加区间。第二,按 18 磅字号加载字形:face.set_char_size(18 << 6, 18 << 6, 150, 150)——尺寸以 6 位小数给出,按 150 dpi(屏幕的物理分辨率)换算成像素。第三,字体栈回退:font_stack 里的 TTF 按优先级排序,某个字符在 Source Sans Pro 里没有时自动落到 Open Sans,这正是脚本把两种字体一起传的原因。
# fontconvert.py:两色打包 —— 每个 4 位 nibble 只可能是纯黑或纯白
for i, v in enumerate(bitmap.buffer):
x = i % bitmap.width
if x % 2 == 0: # 偶数列 → 低 nibble
if two_color:
px = 0xF if v > 128 else 0 # v>128 判黑,否则判白
else:
px = v >> 4 # 普通模式:保留 4 位灰度
else: # 奇数列 → 高 nibble
if two_color:
px = px | (0xF0 if v > 128 else 0)
else:
px = px | (v & 0xF0)
pixels.append(px) # 每两个像素打包成一个字节
compressed = packed
if compress:
compressed = zlib.compress(packed) # --compress:字形位图整体 zlib 压缩
第四,把结果写成 C 头文件。所谓点阵字体(也叫位图字体),就是每个字符用一张小像素图表示——本章生成的正是这种字体。产物是一个四件套:XXXBitmaps[](压缩字形位图)、XXXGlyphs[](每个字形的描述符)、XXXIntervals[](码点区间 → 字形偏移)、以及一个 EpdFont 结构体把它们串起来。以 regular_font.h 结尾的真实结构体为例:{ regular_fontBitmaps, regular_fontGlyphs, regular_fontIntervals, 79, 1, 47, 37, -11 }——依次是 79 个字符区间、1(压缩标志,位图已 zlib 压缩)、字体高 47 像素、上伸部 37、下探部 −11。每个字形描述符则长这样:{ 21, 25, 20, 0, 25, 70, 1572 } 表示大写 A 宽 21、高 25、字距 20、其 70 字节的压缩位图存放在 Bitmaps 数组第 1572 字节处。EPDiy 渲染时靠 Intervals 把字符码点定位到字形、再解压位图。
/* lib/Fonts/regular_font.h 末尾 —— EpdFont 结构体(真实数据) */
const EpdFont regular_font = {
regular_fontBitmaps, // 压缩字形位图(56,399 字节)
regular_fontGlyphs, // 946 个字形描述符
regular_fontIntervals, // 79 个码点区间
79, // 区间个数
1, // 位图已压缩(绘制时解压)
47, // 字体高度(像素)
37, // ascender
-11, // descender
};
| 阶段 | 谁负责 | 产物 |
|---|---|---|
| 拉取字体 | generate_fonts.sh(curl) | Source Sans Pro + Open Sans 各 4 个 TTF |
| 选字符集 | fontconvert.py 内置 intervals | 拉丁 + 西里尔 + 标点(可用 --additional-intervals 扩展) |
| 栅格化 + 两色打包 | freetype-py + --two-color | 每 2 像素 1 字节,仅 0/255 两色 |
| 压缩 + 输出 | --compress(zlib) | lib/Fonts/*.h 四件套头文件 |
字体大小是"生成期"参数,不是"运行期"可变的。想换字号就得重新跑 fontconvert.py、重新 #include 新的头文件、重新烧固件——不像桌面系统可以在运行时 setFontSize()。这也是嵌入式字体"预栅格化"思路的代价:提前在 PC 上把字形烤成位图,MCU 只负责查表解压,不负责缩放字体。仓库里 scripts/get_intervals_from_font.py 就是辅助工具:给定一个 TTF,它把该字体实际包含的码点区间列出来,帮你决定把哪些区间编进 intervals。
上一节那个 --two-color 是整条字体流水线里最关键的一个开关。普通字体栅格化后,字形边缘会有抗锯齿的中间灰度——像素值介于纯黑和纯白之间;而两色打包把每个像素的 4 位值只压成两种:0(白)或 0xF(黑),v > 128 判黑、否则判白。于是写进帧缓冲的像素永远只有 0 / 255。这正好接上第 12 章那条刷新分档:Renderer::needs_gray(color) 只在 color != 0 && color != 255 时置位 needs_gray_flush,而两色字形从不产生中间灰阶——所以纯文字页始终走又快又省的 MODE_DU 直接更新,而不必坠入要几百毫秒的 MODE_GC16 灰阶全刷。
这一点在 draw_text 里被刻成了一个显眼的"遗迹":那行 needs_gray_flush = true; 被注释掉了,旁边写着 "if using antialised text then set to gray next flush"。也就是说,如果哪天你换回灰度字体,这行注释就是恢复抗锯齿渲染的开关——而代价是每一页都要整屏慢刷。作者的取舍很明白:不要那点边缘平滑,要的是"像翻纸质书一样快"。
/* EpdiyFrameBufferRenderer.h:两色字体 = 永不产生中间灰阶 */
void draw_text(int x, int y, const char *text, bool bold, bool italic) {
// if using antialised text then set to gray next flush
// needs_gray_flush = true; // 两色字体不需要它,翻页才能快
epd_write_string(get_font(bold, italic), text,
&xpos, &ypos, m_frame_buffer, &m_font_props);
}
/* needs_gray:非纯黑白才标记灰阶,需要慢刷 */
void needs_gray(uint8_t color) {
if (color != 0 && color != 255) needs_gray_flush = true;
}
两色之外还有第二层优化——压缩。字形位图是高度规律的稀疏二值数据,用 zlib 压一遍体积大减:大写 A 的 21×25 位图原本要 263 字节(2 像素/字节),压缩后只有 70 字节。EPDiy 渲染时根据 EpdFont 里的压缩标志,在绘制过程中逐字形解压、写进帧缓冲。压缩在"生成期"(PC 上)完成,解压在"绘制期"(MCU 上)完成——把最贵的压缩成本前置,MCU 只付出一次廉价解压。四个字体合计约 242 KB 的压缩数据,正是这条"两色 + 压缩"流水线的成绩单。
| 维度 | 灰度(抗锯齿)字体 | 两色字体(本项目) |
|---|---|---|
| 像素值 | 出现 1..254 中间灰阶 | 仅 0 / 255 纯黑白 |
| 刷新模式 | 强制 MODE_GC16(数百毫秒) | MODE_DU 快速直接更新 |
| 字形体积 | 4 位灰度,压缩率一般 | 二值 + zlib,压缩率更高 |
| 观感 | 边缘平滑 | 边缘略硬,换取翻页速度 |
两色字体是"用观感的 5% 换速度的 200%"的典型权衡。想在工程里复用它,请记住这条因果链:两色字形 → 无中间灰阶 → needs_gray 不置位 → flush_display 走 MODE_DU → 翻页变快。任何破坏第一环的改动(换灰度字体、启用抗锯齿、画进灰阶图标)都会让链条失效,让整页退回慢刷——改代码前先顺着这条链检查一遍。
这套字体系统目前只有 regular_font.h 一个文件就有约 946 个字形,四个字体加起来约 242 KB 压缩数据——对一个英文阅读器,这个量很健康。可一旦你想让它读中文书,麻烦立刻放大。汉字不是靠"26 个字母组合"生成字形的:每个汉字都是一个独立的字形。常用汉字约 3500 个,GB2312 一级字库有 3755 个,而完整的 CJK 统一表意区(0x4E00–0x9FFF)更是有 20,992 个码位。对照之下,现在整个字体系统才 946 个字形。
字形本身也大得多。拉丁字母 18 磅时高 47 像素、宽不过 20 像素;而汉字是方块字,宽度基本等于字高——一个 18 磅汉字大约是 40×47 像素。按每 2 像素 1 字节算,一个汉字位图约 940 字节(未压缩)。乘一下就是触目惊心的账:3755 个一级常用字 ≈ 3.5 MB 未压缩,四个风格就是约 14 MB;若塞满整个 CJK 区 20992 个码位,单风格就接近 20 MB。而这张 Flash 总共只有 4 MB——查 partitions.csv,留给固件的 app0 分区 0x120000 只有约 1.13 MB,字体又是编译进固件的。一个风格的中文字体就顶得上整个固件分区的三倍。
/* partitions.csv:4 MB Flash 的分区 —— 字体住在 app0 里 */
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x120000, // 应用分区 ≈ 1.13 MB
spiffs, data, spiffs, 0x130000, 0x2D0000, // 文件系统 ≈ 2.81 MB
/* 一个 18 磅汉字的体积估算(未压缩) */
// 40 px × 47 px ÷ 2 像素/字节 ≈ 940 字节/字
// 3755 一级常用字 × 940 B ≈ 3.5 MB —— 单风格就爆掉 app0 分区
除了体积,还有两道暗礁。第一道在生成期:fontconvert.py 的 load_glyph() 只会在字体栈里找码位,找不到就 raise ValueError 直接终止——而 Source Sans Pro 和 Open Sans 都不含 CJK 字形,你不往字体栈里叠一个中文字体,脚本根本跑不完。第二道在绘制期:汉字笔画密集,zlib 压缩率远不如稀疏的拉丁字形,且每帧要解压的字形数据量成倍增加,翻页会更慢;epd_get_text_bounds 做文本测量也要扫过这些大位图。内存与速度两头受挤压。
所以"给这个阅读器加中文"从来不是改一行配置的事。现实里的常见路线有三条:子集化——只把项目真正用到的那几百个汉字(书名、作者、目录)编进 intervals,配一个 CJK 字体做后备;换更省的方法——用更高的 --additional-intervals 精确圈定范围,配合 get_intervals_from_font.py 先探查字体覆盖;或者接受更大预算——换更大 Flash 的模组、把字体放进文件系统而不是固件。作者选择在 README 里坦白"only generated glyphs for Latin characters and punctuation",正是把这道墙明明白白立在了读者面前。
| 方案 | 代价 | 可行性 |
|---|---|---|
| 全量 CJK(20992 字) | 单风格约 20 MB 未压缩,四个风格超整片 4 MB Flash | 不可行 |
| 一级常用字(3755 字) | 单风格约 3.5 MB 未压缩,仍超 app0 分区(1.13 MB) | 需强压缩 + 精选子集 |
| 子集化(几百常用字) | 可控,但书里没编入的字会缺失 | 最现实 |
压缩并不能免费解决汉字问题。字形位图是 0/1 二值数据,拉丁字母笔画稀疏、空白多,zlib 能压到原来三成以下;汉字笔画又密又粗,有效信息占比高,压缩率差得多——3.5 MB 未压缩的汉字集,压完很可能仍在 1.5 MB 以上,对 1.13 MB 的 app0 分区依然是坏消息。估算内存时,别拿"未压缩 × 压缩率"的乐观数字糊弄自己,最好实际跑一遍脚本看输出体积。
阅读 EpdiyFrameBufferRenderer.h 里的 get_font(bool is_bold, bool is_italic),写出它返回四个字体的四种组合条件,并说明为什么 HTML 里只需要 <b> 和 <i> 两个标签就能和这四套字体一一对应。
两个布尔值共四种组合;HTML 的强调标签只有粗体 <b> 与斜体 <i> 两种,可叠加。
bold && italic → bold_italic_font;仅 bold → bold_font;仅 italic → italic_font;否则 → regular_font。HTML 解析器只把 <b> 记为粗体、<i> 记为斜体,且一个词可以两种都占——所以两个布尔标志正好覆盖全部可能(不粗不斜 / 粗 / 斜 / 粗斜),四套字体与此一一对应,不多不少。
仿照 fontconvert.py 的两色逻辑,把一行 8 个像素(灰度值 [200, 30, 40, 160, 250, 128, 127, 0])打包成字节。规则:偶数列进低 nibble、奇数列进高 nibble,v > 128 判黑(0xF)、否则判白(0)。写出 4 个输出字节,并指出哪些像素会被判黑。
逐一配对:像素对 (200,30)、(40,160)、(250,128)、(127,0)。注意 128 不满足 > 128,是白的。
像素对 (200,30) → 低 nibble 黑(0xF)、高 nibble 白(0x0) → 0x0F;(40,160) → 低白高黑 → 0xF0;(250,128) → 低黑高白(128 不 >128)→ 0x0F;(127,0) → 全白 → 0x00。判黑的是 200、160、250 三个像素。可见打包后每个字节拆开还原时,只有 0 和 255 两种灰度值——这就是"两色"。
基于 14.4 的数字,估算:(1) 只支持 3500 个常用汉字、单风格、未压缩,约需多少字节?(2) 按 40% 的乐观压缩率算,压完约多少 MB?(3) 对照 partitions.csv 的 app0 分区大小,得出结论。再想想为什么"四个风格全量中文"连 4 MB 整片 Flash 都装不下。
单字约 940 字节;app0 = 0x120000 = 1,179,648 字节 ≈ 1.13 MB;压缩后仍有 1.3 MB 以上。
(1) 3500 × 940 ≈ 3,290,000 字节 ≈ 3.3 MB。(2) 按 40% 压缩率 ≈ 1.3 MB,仍超过 app0 的约 1.13 MB。(3) 因此连"单风格、只做常用字、还给了乐观压缩率"都放不进应用分区,必须子集化或换更大预算。而全量四个风格 ≈ 3500×4×940×40% ≈ 5.0 MB 压缩后——加上固件与 SPIFFS 分区,已经超过整片 4 MB Flash。汉字数是英文阅读器字体系统绕不过去的墙。
假设你要让这台设备能读一本中文书,请给出一个可落地的方案,至少包含三处改动:字符区间、字体栈、生成参数/存储位置。针对每一处,指出不改会出什么问题(结合 fontconvert.py 的 load_glyph、--additional-intervals、以及 14.3 的刷新因果链)。
load_glyph 找不到码位会 raise;CJK 字体要进 font_stack;区间要么子集要么用 additional-intervals;存储可从固件移到文件系统。
(1) 字符区间:用 --additional-intervals 把书的实际用字圈进 intervals,或先用 get_intervals_from_font.py 探查字体覆盖——否则 load_glyph 在遇到 CJK 码位时 raise ValueError,脚本直接中断。(2) 字体栈:往 font_stack 里加一个 CJK 字体(如思源宋体子集)并排在 Source Sans Pro 之后做后备——否则汉字从任何字体都查不到。(3) 生成参数/存储:把字号、子集规模压到 app0 分区能装下的范围,或把字体数据放进 SPIFFS 文件系统而非固件(代价是上电要加载);同时保持两色输出,否则中文字形边缘一旦出现抗锯齿灰阶,会让 needs_gray 置位、整页退回 MODE_GC16 慢刷。这三点环环相扣,改一处而不动其余,流水线都会在某一步断掉。