Files
MeowOS-For-R3P2/font/gen/gen_font.py
T

323 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
gen_font.py — MeowOS 预渲染字库生成器(主机侧)· v2:像素进 PNG、索引进 idx。
产出(单份存储,设备与主机共用同一套字节):
<out>/font.png 灰度 PNG(bit depth 4|8),含全部字号的面,每面一个子矩形
<out>/font.idx 二进制索引:头部 + 每面元数据 + 每字符 16B 记录
设计要点:
* 像素只存一次(font.png);idx 只做索引。设备端用 zlib inflate + 自写 PNG 反滤波解码,
无需 libpng(目标根文件系统无 libpng,仅有 zlib)。
* 位深默认 4(16 级 alpha,仍抗锯齿)以把多张高密度汉字面压到 ≤2MB 预算内;8-bit 会超。
--bitdepth 8 可出 8-bit(建议单字号)。
* 绘制模型:drawtext(fb,x,y,str,color,size),(x,y)=行首「升线顶」锚点;baseline=y+ascent;
每字形 blit 于 (x+pen+dx, y+dy),pen 累加 advance。
idx 二进制布局(little-endian, version=2):
Header(16B):
+0 u8[4] magic='MFNI'
+4 u16 version=2
+6 u8 bit_depth (4|8)
+7 u8 face_count
+8 u32 png_w
+12 u32 png_h
FaceHeader ×face_count (20B each):
+0 u16 px_size
+2 u16 plane_x
+4 u16 plane_y (PNG 像素内面左上角)
+6 u16 plane_w
+8 u16 plane_h
+10 i16 ascent
+12 i16 descent (负值)
+14 u16 glyph_count
+16 u32 glyph_offset (文件内本面 glyph 表偏移)
Glyph 表(每面按 codepoint 升序,16B each):
+0 u32 codepoint
+4 u16 x (PNG 像素左上角,已含 offset)
+6 u16 y
+8 u16 w
+10 u16 h
+12 u16 advance
+14 i8 dx
+15 u8 dy
"""
import argparse
import os
import struct
import zlib
from PIL import Image, ImageDraw, ImageFont
MAGIC = b"MFNI"
VER = 2
# --------------------------------------------------------------------------
# PNG 编码(灰度, bit depth 4|8, 全部扫描行 filter=0;规范解码器可读)
# --------------------------------------------------------------------------
def _png_chunk(ctype, data):
c = ctype + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
def gray_png_encode(width, height, depth, packed):
rowbytes = (width * depth + 7) // 8
ihdr = struct.pack(">IIBBBBB", width, height, depth, 0, 0, 0, 0) # gray, compression0 filter0 interlace0
raw = bytearray()
for y in range(height):
raw.append(0) # filter type 0 (None)
raw += packed[y * rowbytes:(y + 1) * rowbytes]
idat = zlib.compress(bytes(raw), 9)
out = b"\x89PNG\r\n\x1a\n"
out += _png_chunk(b"IHDR", ihdr)
out += _png_chunk(b"IDAT", idat)
out += _png_chunk(b"IEND", b"")
return out
def pack_plane_depth(plane_l8, width, height, depth):
"""把 L8 行主序 plane 转为指定 bit depth 的打包字节。"""
if depth == 8:
return bytes(plane_l8)
assert depth == 4
rowbytes = (width + 1) // 2
out = bytearray()
for y in range(height):
base = y * width
row = bytearray(rowbytes)
for x in range(0, width, 2):
a = plane_l8[base + x]
b = plane_l8[base + x + 1] if x + 1 < width else 0
row[x // 2] = ((a >> 4) << 4) | (b >> 4)
out += row
return bytes(out)
# --------------------------------------------------------------------------
# 字符集
# --------------------------------------------------------------------------
def build_charset(extra=""):
cps = []
def add(cp):
if cp not in cps:
cps.append(cp)
for c in range(0x20, 0x7F):
add(c)
for hi in range(0xB0, 0xD8):
for lo in range(0xA1, 0xFF):
try:
add(ord(bytes([hi, lo]).decode("gb2312")))
except UnicodeDecodeError:
pass
for hi in range(0xA1, 0xA4):
for lo in range(0xA1, 0xFF):
try:
add(ord(bytes([hi, lo]).decode("gb2312")))
except UnicodeDecodeError:
pass
for ch in extra:
add(ord(ch))
return sorted(cps)
# --------------------------------------------------------------------------
# 逐字形渲染
# --------------------------------------------------------------------------
class Glyph:
__slots__ = ("cp", "w", "h", "dx", "dy", "adv", "pixels")
def __init__(self, cp, w, h, dx, dy, adv, pixels):
self.cp, self.w, self.h, self.dx = cp, w, h, dx
self.dy, self.adv, self.pixels = dy, adv, pixels
def render_one(font, ch, pad):
asc, desc = font.getmetrics()
# Pillow: ascent & descent 均为正; 字体行高 = asc + |desc|(desc 或为负,用 abs 归一)
line_h = asc + abs(desc)
Wc = int(font.getlength(ch)) + 2 * pad + 16
Hc = line_h + 2 * pad
img = Image.new("L", (Wc, Hc), 0)
ImageDraw.Draw(img).text((pad, pad), ch, font=font, fill=255)
bb = img.getbbox()
if bb is None:
return Glyph(ord(ch), 0, 0, 0, 0, int(round(font.getlength(ch))), None)
l, t, r, b = bb
return Glyph(ord(ch), r - l, b - t, l - pad, t - pad,
int(round(font.getlength(ch))), img.crop((l, t, r, b)).tobytes())
# --------------------------------------------------------------------------
# 打包(decreasing-height 书架式;枚举多个最大宽求最小面积)
# --------------------------------------------------------------------------
def pack_best(glyphs, gap=1):
best = None
for max_w in (256, 384, 512, 640, 768, 1024, 1280, 1536, 2048, 3072):
items = sorted(enumerate(glyphs), key=lambda t: -t[1].h)
placements = {}
x = y = 0
shelf_h = 0
cur_w = 0
for idx, g in items:
if g.w == 0 or g.h == 0:
continue
if cur_w + g.w > max_w:
y += shelf_h + gap
x = 0
cur_w = 0
shelf_h = 0
placements[idx] = (x, y)
x += g.w + gap
cur_w += g.w + gap
if g.h > shelf_h:
shelf_h = g.h
if not placements:
continue
aw = max(placements[i][0] + g.w for i, g in enumerate(glyphs) if i in placements)
ah = y + shelf_h
area = aw * ah
if best is None or area < best[0]:
best = (area, placements, aw, ah, max_w)
if best is None:
return {}, 0, 0, 0
return best[1], best[2], best[3], best[4]
# --------------------------------------------------------------------------
# idx 写入
# --------------------------------------------------------------------------
def write_idx(path, bit_depth, faces, glyph_tabs):
n = len(faces)
png_w = max(f["plane_x"] + f["plane_w"] for f in faces)
png_h = max(f["plane_y"] + f["plane_h"] for f in faces)
header = bytearray(struct.pack("<4sHBBII", MAGIC, VER, bit_depth, n, png_w, png_h))
assert len(header) == 16
face_off = 16
glyph_off = face_off + 20 * n
body = bytearray()
for i, f in enumerate(faces):
body += struct.pack("<HHHHHhhHI", f["px"], f["plane_x"], f["plane_y"],
f["plane_w"], f["plane_h"], f["ascent"], f["descent"],
len(glyph_tabs[i]), 0)
cur = glyph_off
tables = []
offs = []
for tab in glyph_tabs:
offs.append(cur)
tab_sorted = sorted(tab, key=lambda g: g[0])
buf = bytearray()
for cp, x, y, w, h, adv, dx, dy in tab_sorted:
dy = max(dy, 0) # 少数全角/符号字形墨迹略高于 la 锚点,得到负 dy;u8 仅能表达非负,钳到 0(整体下移 1-3px,肉眼不可见)
buf += struct.pack("<IHHHHHbB", cp, x, y, w, h, adv, dx, dy)
tables.append(buf)
cur += len(buf)
for i, off in enumerate(offs):
struct.pack_into("<I", body, i * 20 + 16, off)
for buf in tables:
body += buf
with open(path, "wb") as fp:
fp.write(bytes(header))
fp.write(bytes(body))
return len(header) + len(body)
# --------------------------------------------------------------------------
# 主流程
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="MeowOS 预渲染字库生成器 v2")
ap.add_argument("--font", required=True)
ap.add_argument("--sizes", default="28,20")
ap.add_argument("--out", required=True)
ap.add_argument("--name", default="font")
ap.add_argument("--bitdepth", type=int, default=4, choices=(4, 8))
ap.add_argument("--extra", default="")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
sizes = [int(s) for s in args.sizes.split(",") if s.strip()]
cps = build_charset(args.extra)
print(f"字符集: {len(cps)} 码位")
all_planes = [] # (aw, ah, plane_l8 bytes, tab)
max_w = 0
for size in sizes:
print(f"\n=== 字号 {size}px ===")
font = ImageFont.truetype(args.font, size)
asc, desc = font.getmetrics()
print(f" metrics ascent={asc} descent={desc}")
glyphs = []
miss = []
for cp in cps:
ch = chr(cp)
g = render_one(font, ch, pad=8)
if g.w == 0 and g.h == 0 and cp != 0x20:
miss.append(ch)
glyphs.append(g)
if miss:
print(f" [warn] {len(miss)} 码位无字形(保留advance): {''.join(miss[:20])}")
placements, aw, ah, chosen = pack_best(glyphs)
print(f" packing atlas {aw}x{ah} (max_w={chosen}) glyphs={len(glyphs)}")
plane_l8 = bytearray(aw * ah)
tab = []
for i, g in enumerate(glyphs):
if i in placements:
px, py = placements[i]
for row in range(g.h):
s = row * g.w
d = (py + row) * aw + px
plane_l8[d:d + g.w] = g.pixels[s:s + g.w]
tab.append((g.cp, px, py, g.w, g.h, g.adv, g.dx, g.dy))
else:
tab.append((g.cp, 0, 0, 0, 0, g.adv, 0, 0))
all_planes.append((aw, ah, bytes(plane_l8), tab))
max_w = max(max_w, aw)
# ---- 编排到公共画布(纵向堆叠,公共宽 max_w 偶数化) ----
if max_w % 2:
max_w += 1
total_h = sum(p[1] for p in all_planes)
canvas_l8 = bytearray(max_w * total_h)
face_meta = []
fy = 0
for i, (aw, ah, plane, tab) in enumerate(all_planes):
for y in range(ah):
src = y * aw
dst = (fy + y) * max_w
canvas_l8[dst:dst + aw] = plane[src:src + aw]
face_meta.append({"px": sizes[i], "plane_x": 0, "plane_y": fy,
"plane_w": aw, "plane_h": ah,
"ascent": int(ImageFont.truetype(args.font, sizes[i]).getmetrics()[0]),
"descent": int(abs(ImageFont.truetype(args.font, sizes[i]).getmetrics()[1]))})
fy += ah
# ---- 打包 + PNG ----
packed = pack_plane_depth(canvas_l8, max_w, total_h, args.bitdepth)
png_bytes = gray_png_encode(max_w, total_h, args.bitdepth, packed)
png_path = os.path.join(args.out, f"{args.name}.png")
with open(png_path, "wb") as fp:
fp.write(png_bytes)
# ---- idx ----
idx_path = os.path.join(args.out, f"{args.name}.idx")
idx_size = write_idx(idx_path, args.bitdepth, face_meta, [p[3] for p in all_planes])
png_size = os.path.getsize(png_path)
total = png_size + idx_size
print(f"\nfont.png {png_size} B")
print(f"font.idx {idx_size} B")
print(f"合计 {total} B = {total/1024:.1f} KiB = {total/1048576:.2f} MiB (预算 2MiB)")
if __name__ == "__main__":
main()