418 lines
17 KiB
Python
418 lines
17 KiB
Python
#!/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()
|