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

418 lines
17 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 hashlib
import json
import os
import struct
import unicodedata
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)
# --------------------------------------------------------------------------
# 字符集(字体档位:common / gbk / full)
# 口径见 docs/MEOWOS_SPEC_REF.md §5.2「字体档位/大字库」与 §5.7.8:
# common ≈ 4k 码位(ASCII + GB2312 常用 + 标点 + --extra) = 现状;
# gbk ≈ 20k+(再并入 CJK 统一表意 U+4E00-9FFF + CJK 符号/全角,含常用简繁/生僻);
# full ≈ 全 Unicode 可渲染区间(gbk + CJK 扩展 A/B-F + 拉丁/希腊/西里尔 + 通用标点,可选)。
# 每档在产物 font.json 写入 charset + glyph_count(见 write_font_json)。
# --------------------------------------------------------------------------
CHARSET_TIERS = ("common", "gbk", "full")
CHARSET_ALIAS = {"0": "common", "common": "common", "1": "gbk", "gbk": "gbk",
"2": "full", "full": "full"}
def build_charset(tier="common", extra=""):
if tier not in CHARSET_TIERS:
tier = "common"
cps = []
def add(cp):
if 0 <= cp <= 0x10FFFF and cp not in cps:
cps.append(cp)
# ASCII 可打印字符
for c in range(0x20, 0x7F):
add(c)
# GB2312 一/二级常用简体(B0-D7 + D8 常用二级首段)
for hi in range(0xB0, 0xD8):
for lo in range(0xA1, 0xFF):
try:
add(ord(bytes([hi, lo]).decode("gb2312")))
except UnicodeDecodeError:
pass
# GB2312 符号/全角(A1-A4)
for hi in range(0xA1, 0xA5):
for lo in range(0xA1, 0xFF):
try:
add(ord(bytes([hi, lo]).decode("gb2312")))
except UnicodeDecodeError:
pass
if tier in ("gbk", "full"):
# CJK 统一表意(含常用简繁/生僻):U+4E00-9FFF ≈ 2万+
for cp in range(0x4E00, 0xA000):
add(cp)
# CJK 符号与标点 + 全角形式(补齐 common 未含的)
for cp in range(0x3000, 0x3040):
add(cp)
for cp in range(0xFF00, 0xFFF0):
add(cp)
if tier == "full":
# 拉丁补充 / 希腊 / 西里尔 等常用非 CJK(Latin-1 起已覆盖 AI 常见重音)
for cp in range(0xA0, 0x0530):
add(cp)
# CJK 扩展 A + 兼容表意
for cp in range(0x3400, 0x4DC0):
add(cp)
for cp in range(0xF900, 0xFB00):
add(cp)
# 通用标点 / 货币等(— … “ ” 等已在 gbk;此处补全)
for cp in range(0x2000, 0x2070):
add(cp)
for cp in range(0x20A0, 0x20D0):
add(cp)
# CJK 扩展 B-F(补充平面)
for cp in range(0x20000, 0x2EBF0):
add(cp)
for cp in range(0x30000, 0x31350):
add(cp)
for ch in extra:
add(ord(ch))
return sorted(cps)
def charset_glyph_count(cps):
"""glyph_count = 字符集码位数(信息用,落 font.json)。"""
return len(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)
# --------------------------------------------------------------------------
# font.json(元数据 + 完整性校验;schema 见 docs/MEOWOS_SPEC_REF.md §5.2)
# --------------------------------------------------------------------------
def write_font_json(out_dir, name, family, weight, tier, glyph_count, sizes, bitdepth,
png_path, idx_path, source="", license_=""):
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for blk in iter(lambda: f.read(1 << 16), b""):
h.update(blk)
return h.hexdigest()
meta = {
"family": family or name, # 同目录名(信息用;扫描端以目录名为 family)
"name": name, # 下拉显示名(缺则回退目录名)
"weight": weight or "regular", # 字重 slug(regular|bold|italic|…;默认 regular)
"glyph_count": glyph_count, # 信息用
"charset": tier, # common|gbk|full(档位;驱动按需加载)
"size_list": sizes, # 原生面字号
"bit_depth": bitdepth, # 4|8
"sha256_png": sha256(png_path), # font.png 校验(扫描端仅 WARN)
"sha256_idx": sha256(idx_path), # font.idx 校验(扫描端仅 WARN)
}
if source: meta["source"] = source # 可选:来源(如 "MiSans Regular (Xiaomi)")
if license_: meta["license"] = license_ # 可选:许可(如 "SIL Open Font License 1.1")
jpath = os.path.join(out_dir, "font.json")
with open(jpath, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
return jpath
# --------------------------------------------------------------------------
# 主流程
# --------------------------------------------------------------------------
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("--family", default="", help="font.json family(缺省 = --name;信息用)")
ap.add_argument("--weight", default="regular",
help="字重 slug(regular|bold|italic|…,缺省 regular;写进 font.json weight)")
ap.add_argument("--charset", default="common", choices=CHARSET_TIERS,
help="字体档位:common≈4k / gbk≈20k+ / full(可选,默认不推)")
ap.add_argument("--bitdepth", type=int, default=4, choices=(4, 8))
ap.add_argument("--extra", default="")
ap.add_argument("--source", default="", help="可选:font.json source 来源说明")
ap.add_argument("--license", default="", help="可选:font.json license 许可")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
sizes = [int(s) for s in args.sizes.split(",") if s.strip()]
tier = CHARSET_ALIAS.get(args.charset, "common")
cps = build_charset(tier, args.extra)
print(f"字符集: {len(cps)} 码位 (charset={tier})")
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])
# ---- font.json(写入 weight + charset + glyph_count + sha256,供扫描端标注档位/大小标记) ----
jpath = write_font_json(args.out, args.name, args.family, args.weight, tier, charset_glyph_count(cps),
sizes, args.bitdepth, png_path, idx_path,
args.source, args.license)
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"font.json {os.path.getsize(jpath)} B (charset={tier}, weight={args.weight or 'regular'}, glyph_count={len(cps)})")
print(f"合计 {total} B = {total/1024:.1f} KiB = {total/1048576:.2f} MiB (预算 2MiB)")
if __name__ == "__main__":
main()