""" 用 PIL 渲染 ASCII 像素风位图字体图集。 用法: python tools/gen_font_atlas.py [output_dir] 默认输出目录: assets/font/ 生成: font_atlas.png, font_atlas.h 字符范围: ASCII 32~126 字体: 优先使用 assets/font/ndrtyyl-prqyys-undertale-hebrew-uppercase.ttf, 不存在时回退到系统等宽字体(Courier New / Consolas / monospace) 像素格式: (R << 24) | (G << 16) | (B << 8) | A 生成端会把 alpha 阈值化为 0/255,匹配当前 DrawContext::draw_text 的像素风绘制路径。 """ import os import sys from PIL import Image, ImageDraw, ImageFont FIRST_CHAR = 32 LAST_CHAR = 126 NUM_CHARS = LAST_CHAR - FIRST_CHAR + 1 # 95 COLUMNS = 16 ROWS = (NUM_CHARS + COLUMNS - 1) // COLUMNS # 6 def find_mono_font(size: int) -> ImageFont.FreeTypeFont: repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) candidates = [ os.path.join(repo_root, "assets/font", "ndrtyyl-prqyys-undertale-hebrew-uppercase.ttf"), "C:/Windows/Fonts/consola.ttf", # Consolas (Windows) "C:/Windows/Fonts/cour.ttf", # Courier New (Windows) "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", # Linux "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", ] for path in candidates: if os.path.exists(path): return ImageFont.truetype(path, size) return ImageFont.load_default() def generate(output_dir: str): font = find_mono_font(14) # 测量完整 ASCII 字符集的尺寸。 # 不能只用 "M":它没有下伸部,按它算高度会裁掉 g/j/p/q/y/_ 等字符。 tmp_img = Image.new("RGBA", (1, 1)) tmp_draw = ImageDraw.Draw(tmp_img) chars = [chr(FIRST_CHAR + i) for i in range(NUM_CHARS)] bboxes = [tmp_draw.textbbox((0, 0), ch, font=font) for ch in chars] pad = 1 min_left = min(b[0] for b in bboxes) min_top = min(b[1] for b in bboxes) max_right = max(b[2] for b in bboxes) max_bottom = max(b[3] for b in bboxes) # 等宽字体仍使用统一 cell。宽度用字体 advance,避免窄字符影响字距; # 高度用全字符 bbox,保证下伸字符不被裁切。 max_advance = max(tmp_draw.textlength(ch, font=font) for ch in chars) char_w = int(max_advance + 0.9999) + pad * 2 char_h = (max_bottom - min_top) + pad * 2 atlas_w = COLUMNS * char_w atlas_h = ROWS * char_h img = Image.new("RGBA", (atlas_w, atlas_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) for i in range(NUM_CHARS): ch = chr(FIRST_CHAR + i) col = i % COLUMNS row = i // COLUMNS x = col * char_w + pad - min_left y = row * char_h + pad - min_top draw.text((x, y), ch, fill=(255, 255, 255, 255), font=font) # 像素风:把 TrueType 抗锯齿 alpha 二值化。 # 渲染端目前只判断 alpha 是否非 0;如果保留半透明边缘,会被画成实心像素导致发胖/毛边。 threshold = 128 for py in range(atlas_h): for px in range(atlas_w): _, _, _, a = img.getpixel((px, py)) img.putpixel((px, py), (255, 255, 255, 255) if a >= threshold else (0, 0, 0, 0)) png_path = os.path.join(output_dir, "font_atlas.png") img.save(png_path) # 生成 C 头文件:将每行像素打包成 bit 行(每像素 1 bit,白色=1,透明=0) # 这样每个 glyph 的一行只需要 char_w/8 个字节 # 但为了简单,这里直接存储为 uint32_t RGBA 像素数组 # 使用时白色像素用指定颜色替换,透明像素跳过 pixels = [] for y in range(atlas_h): for x in range(atlas_w): r, g, b, a = img.getpixel((x, y)) pixels.append((r << 24) | (g << 16) | (b << 8) | a) h_path = os.path.join(output_dir, "font_atlas.h") with open(h_path, "w", encoding="utf-8") as f: f.write("// Auto-generated by tools/gen_font_atlas.py\n") f.write("#pragma once\n#include \n\n") f.write(f"static const int32_t font_atlas_width = {atlas_w};\n") f.write(f"static const int32_t font_atlas_height = {atlas_h};\n") f.write(f"static const int32_t font_char_w = {char_w};\n") f.write(f"static const int32_t font_char_h = {char_h};\n") f.write(f"static const int32_t font_first_char = {FIRST_CHAR};\n") f.write(f"static const int32_t font_columns = {COLUMNS};\n\n") f.write(f"static const uint32_t font_atlas_pixels[] = {{\n") for i in range(0, len(pixels), 16): chunk = pixels[i:i + 16] line = ", ".join(f"0x{p:08X}" for p in chunk) f.write(f" {line},\n") f.write("};\n") print(f"Atlas: {atlas_w}x{atlas_h}, char: {char_w}x{char_h}") print(f"Generated: {png_path}, {h_path}") def main(): if len(sys.argv) >= 2: out_dir = sys.argv[1] else: out_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "font" ) os.makedirs(out_dir, exist_ok=True) generate(out_dir) if __name__ == "__main__": main()