折腾笔记[64]-使用python编译c语言代码解析ttf字体
摘要
本文以 ttf_window 示例工程为线索折腾 TTF 字体解析:先介绍用纯 Python 编写的编译器基础设施 PPCI(Pure Python Compiler Infrastructure)把 C 代码编译为机器码的路线,并实际落地了一个跨平台(Windows Win32 / macOS Cocoa)的 C 程序——把 SmileySans-Oblique.ttf(得意黑)内嵌进可执行文件,运行时释放到临时目录,用单文件版 ttf_parser.h 解析字体并把文字描边绘制到窗口中。随后把整个渲染器逐函数翻译为只依赖 Python 3 标准库的 ttf_parser.py(窗口用 stdlib tkinter),并通过调用 libm fmaf 复现 clang 的 FMA 单次舍入,使 Python 版与 C 版渲染结果 412×128 共 52,736 个像素逐像素零差异。
声明
本文人类为第一作者, 智能体为通讯作者. 本文有AI生成内容.
资源下载
工程全部源文件与内嵌字体数据已打包发布在 GitHub Release(v1 / Ver2610061005),可单独下载:
| 文件 | 大小 | 说明 |
|---|---|---|
| window.c | 9.6 KB | C 版窗口程序(Win32 / Cocoa) |
| ttf_parser.h | 163 KB | stb 风格单文件 TTF 解析/渲染库 |
| smiley_ttf_data.h | 12.7 MB | 内嵌得意黑字体的 C 字节数组(2,629,764 字节) |
| window.py | 3.4 MB | 纯标准库 Python 版(含 base85 内嵌字体) |
| ttf_parser.py | 45 KB | ttf_parser.h 的 Python 翻译(1,566 行) |
下载 window.c + ttf_parser.h + smiley_ttf_data.h 三个文件即可按下文命令编译 C 版;下载 window.py + ttf_parser.py 两个文件直接 python3 window.py 运行(window.py 已内嵌字体,无需额外数据文件)。
前言
本文基于 ttf_window 示例工程展开,该工程将 SmileySans-Oblique.ttf(得意黑字体)以二进制数组形式直接编译进可执行文件 window.exe。程序运行时先将内嵌字体释放到临时目录,再使用 ttf_parser 库解析 TrueType 字体数据,最后将文字绘制到 Windows 窗口中。
TrueType 字体解析与描边渲染部分来自开源项目 Maxenceee/ttf_parser,并已合并为 stb 风格的单文件头 ttf_parser.h(将原本的 11 个头文件和 77 个 .c 文件全部整合到一个头文件中),极大简化了集成方式。
工程中同时附带了多套小型编译器源码(Tiny C Compiler、PPCI 等),用于在不同编译环境下构建、验证和实验该程序。
简介
ttf_parser 库简介
[https://github.com/Maxenceee/ttf_parser]
ttf_parser 是一个轻量级的 TrueType 字体解析与渲染库,采用 stb 风格(单头文件)设计,便于直接嵌入到 C 项目中而无需复杂的构建系统。
其核心功能包括:
- TTF 解析:支持解析字体的
name、head、cmap、hhea、kern、glyf等关键表结构,用于提取字形轮廓与度量信息 - 字形轮廓处理:解析 TrueType 字形轮廓点并支持轮廓绘制
- 文字测量与渲染:提供
text_width()、text_height()、draw_text()等 API,可将文字绘制到离屏缓冲区(t_img) - 简洁的 API 封装:保留原
font.h的命名风格,以 inline 包装形式对外提供,易于使用
在 ttf_window 示例中,字体解析成功后通过离屏 32bpp 缓冲区绘制文字,再利用 Win32 的 StretchDIBits 将缓冲贴图到窗口客户区,从而实现内嵌字体的窗口渲染效果。
使用方式(stb 风格):
#define TTF_PARSER_IMPLEMENTATION /* 且仅在一个 .c 文件中定义一次 */
#define TTF_PARSER_NO_MLX /* 不需要 MiniLibX 时定义,避免额外依赖 */
#include "ttf_parser.h"
TCC(Tiny C Compiler)简介
[https://bellard.org/tcc/]
Tiny C Compiler(TCC) 是 Fabrice Bellard 开发的极简、快速的 C 编译器。其特点是编译速度极快、体积小巧、可作为 C 语言的解释器或 JIT 编译器使用,同时也支持生成可执行文件。
ttf_window 目录中包含的 tinycc-43c7708/ 是 TCC 的源码快照(commit 43c7708,2026-10),在本地已通过 clang 配置并构建出 macOS arm64 原生的 tcc(版本 0.9.28rc)。TCC 还自带 Windows 交叉编译能力,可通过 make cross 构建 i386-win32、x86_64-win32、arm64-win32 等目标平台的编译器,从而实现在 macOS 上直接编译生成 window.exe。
相比传统编译器(GCC/Clang),TCC 更适合用于快速验证、编译器实验以及本次示例这种小型单文件 C 程序的编译。
PPCI(Pure Python Compiler Infrastructure)简介
[https://github.com/windelbouwman/ppci]
PPCI 是一个完全用 Python 编写的编译器基础设施(Compiler Infrastructure),目标是提供易于理解、可扩展的编译器实现。其核心特性包括:
- 纯 Python 实现:整个编译器链路(前端、IR、中间优化、后端代码生成)均由 Python 编写,便于调试、学习和二次开发
- C 前端支持:包含 C 语言前端,可解析和编译 C 源码
- 多架构后端:支持多种目标架构,包括 x86_64、ARM、AVR、RISC-V、MIPS 等
- 多种输出格式:支持生成 ELF、EXE(PE)、S-Record、Hex 文件等目标格式
- 可作为库使用:提供 Python API,可脚本化控制整个编译流程
ttf_window 中的 ppci-master/ 为 PPCI 0.5.9 源码树(快照 f0e9aa0b)。其 examples/win64/make.py 演示了如何完全通过 Python API 将 C 源码编译并链接为 64 位 Windows PE 可执行文件(hello.exe)。
最初的设想正是利用 PPCI 这一纯 Python 的 C 编译器直接编译单文件版 ttf_parser.h;但 PPCI 的 C 前端面向教学用途、对真实工程里的 C 语法支持有限,逐函数翻译反而能完全掌控渲染逻辑与数值行为。因此本文最终落地的路线是:先用常规 C 编译器(tcc / zig / clang)构建跨平台 C 程序作为像素基准,再把 C 代码逐函数翻译为纯 Python 3 标准库实现,两者逐像素对照(见下文「工程」)。PPCI 编译 ttf_parser.h 的实验留作后续。
Csmith 简介
[https://github.com/csmith-project/csmith]
Csmith 是一个用于生成随机 C 程序的工具,主要用于编译器的差分测试(Differential Testing)。它可以自动生成语法正确、语义定义良好的随机 C 测试用例,用于对比不同编译器(如 TCC、PPCI、GCC、Clang 等)的输出结果,从而发现编译器中的潜在 Bug。
在 ttf_window 工程中,csmith-master.zip 为 Csmith 的源码快照(0cdc710),目前尚未解压,预留用于后续对编译器进行 Fuzz/差分测试实验。
工程
1. 总体流程
整个程序只有四个文件,平台相关代码被压到最薄:
| 文件 | 作用 |
|---|---|
window.c / window.py |
释放字体 → 解析 → 离屏渲染 → 贴图到窗口 |
ttf_parser.h / ttf_parser.py |
stb 风格单文件 TTF 解析/描边渲染库 |
smiley_ttf_data.h / window.py 内的 base85 字符串 |
内嵌的得意黑字体(2,629,764 字节) |
运行时流程在 C 和 Python 两个版本里完全一致:
- 释放字体:把内嵌字节写到临时目录的
ttf_parser_smiley.ttf(Windows 用GetTempPathA/ Python 用tempfile.gettempdir(),macOS 用$TMPDIR); - 解析字体:
create_new_font("ttf", path, "smiley")读取name/head/cmap/hhea/kern/glyf等表; - 离屏渲染:在 32bpp 缓冲上先铺背景色,再画两行文字——48px 琥珀黄
SmileySans Embedded!和 26px 浅青蓝extracted from window.exe; - 贴图显示:把缓冲交给窗口后端(Win32
StretchDIBits/ CocoaNSImageView/ tkinterPhotoImage); - 清理:关窗时注销字体、删除临时文件。
2. 离屏渲染:布局与"下移 5 像素"的坑
画布尺寸按两行文字的度量动态计算。得意黑的 units_per_em=1000、ascent=970、descent=-230;这两个字号下测得第一行宽 412、高 57,第二行宽 264、高 31,于是画布为 412×128:
top1 = 8;
top2 = top1 + h1 + 20;
g_bw = (w1 > w2 ? w1 : w2); /* 412 */
g_bh = top2 + h2 + 12; /* 128 */
ttf_parser 的坐标变换函数 reflect() 里带一个固定的 -5 偏移,会把字形顶部裁掉。原工程的绕过办法是设置绘制上下文时把文字整体下移:
sf = (float)size / (float)font->props.units_per_em;
ctx.height = (int)((top + 5.0f) / (sf * sf));
另外注意两点渲染特性:上游的字形填充代码没有启用,实际效果是 1px 描边(空心字);map_code() 只接受单字节字符码,所以示例文本只能用英文,中文无法映射。
3. 窗口后端:一套渲染,三套贴图
离屏缓冲的像素布局在所有后端间统一为小端 32bpp:内存字节序 B,G,R,X,当作 uint32 读就是 0x00RRGGBB。因此渲染代码零改动,只有"贴图"一步分平台:
- Windows:
BITMAPINFOHEADER设biHeight = -g_bh(自上而下)、BI_RGB、32bpp,在WM_PAINT中StretchDIBits(..., SRCCOPY); - macOS:纯 C99 通过 Objective-C runtime(
objc_msgSend)调 AppKit,不写一行 Objective-C 语法;像素按kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst包成CGImage交给NSImageView,运行时注册一个TTFWindowDelegate处理"关窗即退出"; - Python:直接用标准库
tkinter,像素列表转成 PPM P6 字节流交给PhotoImage,在Canvas上按内边距 (40, 36) 放置;窗口标题、外框底色#101218都与 C 版一致。
4. 逐函数翻译为纯标准库 Python
这是本文的重头戏:ttf_parser.h(167 KB)翻译为 1,566 行的 ttf_parser.py,只用了 ctypes / struct / math,API 与 C 版同名(create_new_font、get_text_width、draw_text、ft_draw_rect……),C 的结构体对应为 Python 类,链表用列表表示。
翻译目标不是"功能差不多",而是像素级一致。为此必须忠实复现 C 的数值语义,踩了三个大坑:
坑一:float32 中间结果必须逐步舍入。 C 的 float 是 IEEE-754 单精度,而 Python 浮点是双精度,直接翻译会导致贝塞尔曲线采样点差出 1 个像素。所有中间结果都要过一遍单精度舍入:
def f32(x):
"""Round a Python number to IEEE-754 single precision (C `float`)."""
return struct.unpack('<f', struct.pack('<f', x))[0]
坑二:clang -O2 的 FMA 融合。 优化开起来后,clang 会把 a*b+c 收缩成 arm64 的 fmadd / fnmsub 指令——乘加只做一次舍入,而 Python 里先乘后加是两次舍入。148 个像素的差异全部来源于此。解决办法是用 ctypes 直接调 libm 的 fmaf()(仍是标准库),在 reflect()、二次贝塞尔采样、直线插值三处热点复现单次舍入:
def _load_fmaf():
candidates = []
try:
candidates.append(ctypes.CDLL(None)) # macOS libSystem
except OSError:
pass
name = ctypes.util.find_library("m")
if name:
try:
candidates.append(ctypes.CDLL(name)) # Linux libm.so
except OSError:
pass
for lib in candidates:
fn = getattr(lib, "fmaf", None)
if fn is not None:
fn.restype = ctypes.c_float
fn.argtypes = (ctypes.c_float, ctypes.c_float, ctypes.c_float)
return fn
return None
_fmaf = _load_fmaf()
def fma(a, b, c):
return _fmaf(a, b, c) if _fmaf is not None else f32(f32(a * b) + c)
贝塞尔曲线的坐标计算于是写成(每个 fma 对应一条融合指令):
t = f32(f32(i) / fsteps)
it = f32(1.0 - t)
a = f32(it * it)
b2 = f32(t * f32(it + it))
c2 = f32(t * t)
x = int(fma(c2, ex, fma(a, sx, f32(b2 * cx))))
y = int(fma(c2, ey, fma(a, sy, f32(b2 * cy))))
坑三:轮廓闭合状态机。 C 用链表指针遍历字形点,轮廓闭合时会把指针回退、重处理当前节点。Python 版按"索引 +1"的直觉写就会漏掉这次重处理,部分字形轮廓被重复/缺失。最终严格照抄指针语义:维护 prev_idx,闭合后把索引拨回 prev_idx,再落到统一的自增逻辑。修掉这一处后,像素差异从 118 直接归零。
此外 C 版里对上游的三处修复也一并保留:打开字体加 O_BINARY(否则在第 274 字节遇到 0x1A 被当 EOF)、br_get_string() 补 NUL 终止、补 ft_delete_font 原型等;C 原代码本身的若干怪癖(查不到 id 时回退到首元素、复合字形用"已变换的 x"再算 y、cmap4 缓存 0 映射后停止查找)也逐 bug 保留——目标是复刻,不是修正。
5. 像素级验证
用 clang -O2 编一个 C 参照程序,把离屏缓冲导出成 412 128\n 头 + 小端 uint32 数组的二进制;Python 版渲染同一文本后逐像素比较:
rendered 412x128
pixel differences vs C reference: 0
52,736 个像素零差异;另外 hook 了两侧的绘图原语(move / line / quadratic),915 条图元轨迹(含反射后坐标)也完全一致。tkinter 侧再把 PhotoImage 的数据回读一遍,52,736 个像素同样零差异,窗口内容区实测 492×200(= 412+2×40 × 128+2×36)。
6. 源码
生成 smiley_ttf_data.h 的脚本(拖入 ttf 即可,C 版用字节数组内嵌):
# ttf2cheader.py:把 ttf 文件转成 C 字节数组头文件
import sys
src, dst = sys.argv[1], sys.argv[2]
data = open(src, "rb").read()
with open(dst, "w") as f:
f.write("/* generated from %s (%d bytes) - do not edit */\n"
% (src, len(data)))
f.write("static const unsigned char smiley_ttf_data[] = {\n")
for i, b in enumerate(data):
if i % 16 == 0:
f.write("\n" if i else "")
f.write("0x%02X," % b)
f.write("\n};\nstatic const unsigned long smiley_ttf_size = %dUL;\n"
% len(data))
window.c(节选:三个平台共用的核心)
#define LINE1 "SmileySans Embedded!"
#define LINE2 "extracted from window.exe"
#define LINE1_SIZE 48
#define LINE2_SIZE 26
#define BG_RGB 0x001E2230
#define FG1_RGB 0x00FFC93C
#define FG2_RGB 0x009CDCFE
#define OUT_BG_RGB 0x00101218
static int extract_embedded_font(void)
{
/* Windows: GetTempPathA();macOS: getenv("TMPDIR"),拼出临时路径 */
FILE *fp = fopen(g_ttf_path, "wb");
if (!fp) return (-1);
size_t n = fwrite(smiley_ttf_data, 1, smiley_ttf_size, fp);
fclose(fp);
return (n == smiley_ttf_size ? 0 : -1);
}
static void draw_line(t_true_type_font *font, const char *text,
uint32_t size, uint32_t color, int top)
{
t_glyph_ctx ctx;
float sf = (float)size / (float)font->props.units_per_em;
ft_bzero(&ctx, sizeof(ctx));
ctx.width = g_bw;
ctx.height = (int)((top + 5.0f) / (sf * sf));
ctx.font_size = size;
ctx.color = color;
draw_text(font, &g_img, text, ctx);
}
static int render_font_image(void)
{
t_true_type_font *font = get_font("smiley");
int w1 = get_text_width(font, LINE1, LINE1_SIZE);
int w2 = get_text_width(font, LINE2, LINE2_SIZE);
int h1 = get_text_height(font, NULL, LINE1_SIZE);
int h2 = get_text_height(font, NULL, LINE2_SIZE);
int top2 = 8 + h1 + 20;
g_bw = (w1 > w2 ? w1 : w2);
g_bh = top2 + h2 + 12;
g_pixels = calloc((size_t)g_bw * g_bh, sizeof(uint32_t));
g_img.addr = (char *)g_pixels;
g_img.bits_per_pixel = 32;
g_img.line_length = g_bw * 4;
g_img.width = g_bw; g_img.height = g_bh;
ft_draw_rect(ft_create_dims(0, 0, g_bw, g_bh), &g_img, BG_RGB);
draw_line(font, LINE1, LINE1_SIZE, FG1_RGB, 8);
draw_line(font, LINE2, LINE2_SIZE, FG2_RGB, top2);
return (0);
}
完整的 WinMain(注册窗口类、AdjustWindowRect、消息循环)与 macOS main(objc_msgSend 串起 NSApplication / NSWindow / NSImageView)共 336 行,限于篇幅不全文列出。
window.py(节选:内嵌字体与 tkinter 显示)
Python 版把 2.6 MB 字体改用 base85 字符串内嵌(3,287,205 个字符,标准库 base64 直接解码,无需 C 那种每行 16 字节的数组):
import base64, os, tempfile, tkinter
import ttf_parser
_FONT_B85 = (
"......" # 实际为 32937 行、每行 100 字符的 base85 字面量,此处省略
)
smiley_ttf_data = base64.b85decode(_FONT_B85)
def extract_embedded_font():
g_ttf_path = os.path.join(tempfile.gettempdir(), "ttf_parser_smiley.ttf")
with open(g_ttf_path, "wb") as fp:
fp.write(smiley_ttf_data)
return g_ttf_path
# 渲染逻辑与 C 版逐行对应:g_bw/g_bh、ctx.height=(top+5)/(sf*sf)、ft_draw_rect……
def _pixels_to_ppm(pixels, w, h):
rgb = bytearray(w * h * 3)
for j, color in enumerate(pixels):
rgb[j*3] = (color >> 16) & 0xFF
rgb[j*3+1] = (color >> 8) & 0xFF
rgb[j*3+2] = color & 0xFF
return ("P6\n%d %d\n255\n" % (w, h)).encode() + bytes(rgb)
root = tkinter.Tk()
root.title("TTF Window - embedded SmileySans")
canvas = tkinter.Canvas(root, width=g_bw + 2*40, height=g_bh + 2*36,
background="#101218", highlightthickness=0)
canvas.pack()
photo = tkinter.PhotoImage(data=_pixels_to_ppm(g_pixels, g_bw, g_bh))
canvas.create_image(40, 36, anchor="nw", image=photo)
canvas.image = photo # 保持引用,防止被 GC
root.protocol("WM_DELETE_WINDOW", lambda: (cleanup_font(), root.destroy()))
root.mainloop()
ttf_parser.py 作为库使用:
import ttf_parser
ttf_parser.create_new_font("ttf", "some.ttf", "f")
font = ttf_parser.get_font("f")
print(ttf_parser.get_text_width(font, "Hello", 32))
img = ttf_parser.Image(400, 100)
ttf_parser.ft_draw_rect(ttf_parser.ft_create_dims(0, 0, 400, 100), img, 0x1E2230)
7. 构建与运行
C 版只需编译 window.c 一个源文件(头文件全部 #include 进来):
# Windows(amd64):tcc / mingw / zig 交叉编译均可
tcc window.c -o window.exe -luser32 -lgdi32 "-Wl,-subsystem=windows"
zig cc -O2 -target x86_64-windows-gnu window.c -o window.exe -luser32 -lgdi32 "-Wl,--subsystem=windows"
# macOS(arm64):只能用 clang(tcc arm64 消化不了 CoreGraphics 头文件)
clang -O2 window.c -o window -framework Cocoa
Python 版零第三方依赖,直接运行(需要自带 tkinter 的 Python,python.org 安装包与 Anaconda 默认包含):
python3 window.py
8. 效果
左图:macOS 上 clang 编译的 C 版(Cocoa 窗口);右图:window.py 经 tkinter 显示的窗口内容区。两者由同一套 412×128 离屏像素驱动,逐像素一致;48px 琥珀黄大字 + 26px 浅青蓝小字,描边空心字形即"未启用填充、只做 1px 描边"的效果。
| C 版(macOS Cocoa 窗口) | Python 版(tkinter 渲染合成) |
|---|---|
![]() |
![]() |
小结
- 常规路线(tcc/zig/clang 编译 C)可以稳定产出跨平台单文件程序,2.7 MB 体积里 2.6 MB 是字体本身;
- PPCI 这类教学型编译器打开了"用 Python 编译 C"的想象空间,但面对真实工程代码时前端支持有限;
- 而把 C 逐函数翻译为纯标准库 Python 完全可行,难点不在语法转换,而在复刻 float32 舍入与 FMA 融合这类数值语义;以 C 编译产物做像素基准、自动化逐像素对照,是验证翻译正确性的有效手段;
- tkinter + PPM 证明标准库也够用:不装任何第三方包,就能把同一张 32bpp 缓冲显示成跨平台窗口。

本文以 `ttf_window` 示例工程为线索折腾 TTF 字体解析:先介绍用纯 Python 编写的编译器基础设施 PPCI(Pure Python Compiler Infrastructure)把 C 代码编译为机器码的路线,并实际落地了一个跨平台(Windows Win32 / macOS Cocoa)的 C 程序——把 `SmileySans-Oblique.ttf`(得意黑)内嵌进可执行文件,运行时释放到临时目录,用单文件版 `ttf_parser.h` 解析字体并把文字描边绘制到窗口中。随后把整个渲染器**逐函数翻译为只依赖 Python 3 标准库的 `ttf_parser.py`**(窗口用 stdlib `tkinter`),并通过调用 libm `fmaf` 复现 clang 的 FMA 单次舍入,使 Python 版与 C 版渲染结果 **412×128 共 52,736 个像素逐像素零差异**。


浙公网安备 33010602011771号