143 lines
5.0 KiB
Python
143 lines
5.0 KiB
Python
"""
|
||
用 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 <cstdint>\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()
|