AIGC标识 折腾笔记[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 两个版本里完全一致:

  1. 释放字体:把内嵌字节写到临时目录的 ttf_parser_smiley.ttf(Windows 用 GetTempPathA / Python 用 tempfile.gettempdir(),macOS 用 $TMPDIR);
  2. 解析字体:create_new_font("ttf", path, "smiley") 读取 name / head / cmap / hhea / kern / glyf 等表;
  3. 离屏渲染:在 32bpp 缓冲上先铺背景色,再画两行文字——48px 琥珀黄 SmileySans Embedded! 和 26px 浅青蓝 extracted from window.exe;
  4. 贴图显示:把缓冲交给窗口后端(Win32 StretchDIBits / Cocoa NSImageView / tkinter PhotoImage);
  5. 清理:关窗时注销字体、删除临时文件。

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 渲染合成)
截屏2026-10-06 08.45 截屏2026-10-06 09.46

小结

  • 常规路线(tcc/zig/clang 编译 C)可以稳定产出跨平台单文件程序,2.7 MB 体积里 2.6 MB 是字体本身;
  • PPCI 这类教学型编译器打开了"用 Python 编译 C"的想象空间,但面对真实工程代码时前端支持有限;
  • 而把 C 逐函数翻译为纯标准库 Python 完全可行,难点不在语法转换,而在复刻 float32 舍入与 FMA 融合这类数值语义;以 C 编译产物做像素基准、自动化逐像素对照,是验证翻译正确性的有效手段;
  • tkinter + PPM 证明标准库也够用:不装任何第三方包,就能把同一张 32bpp 缓冲显示成跨平台窗口。
posted @ 2026-10-06 10:27  qsBye  阅读(2)  评论(0)    收藏  举报