K1Guitar/tools/gen_wqy_font.py

346 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.

# -*- coding: utf-8 -*-
"""Generate LCD 1bpp arrays from WenQuanYi Bitmap Song (BDF) + optional Micro Hei.
Rollback: git reset --hard checkpoint/misans-before-wqy
Usage:
python tools/gen_wqy_font.py --only=8,9,11,13 --ascii
"""
from __future__ import annotations
import os
import re
import sys
from PIL import Image
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJ_DOC = os.path.normpath(os.path.join(ROOT, "..", "..", "Doc", "UI", "WQY"))
# ROOT is Code/.../tools -> .. is project; Doc is sibling of Code under 一诺国际吉他
PROJ_ROOT = os.path.dirname(os.path.dirname(ROOT)) # 一诺国际吉他 if ROOT=.../Code/YNGJ...
# Fix: ROOT = .../YNGJ-GT1-M - AT32F403ARCT7, parent of Code folder:
CODE_PARENT = os.path.dirname(ROOT) # Code
REPO = os.path.dirname(CODE_PARENT) # 一诺国际吉他
WQY_DIR = os.path.join(REPO, "Doc", "UI", "WQY", "wqy-bitmapsong")
MICROHEI = os.path.join(REPO, "Doc", "UI", "WQY", "wqy-microhei", "wqy-microhei.ttc")
HEADER_CN = os.path.join(ROOT, "device", "LCD_ILI9341", "Drv_ILI9341_Lcd_ChineseFont.h")
HEADER_EN = os.path.join(ROOT, "device", "LCD_ILI9341", "Drv_ILI9341_Lcd_EnglishFont.h")
# Firmware size -> preferred BDF (native cell noted in WQY readme)
# 9pt=12x12, 10pt=13x13, 11pt=15x15, 12pt=16x16, 13px=14x14
SIZE_TO_BDF = {
8: "wenquanyi_9pt.bdf", # scale 12 -> 8
9: "wenquanyi_9pt.bdf", # scale 12 -> 9
11: "wenquanyi_9pt.bdf", # scale 12 -> 11
12: "wenquanyi_9pt.bdf", # native 12
13: "wenquanyi_10pt.bdf", # native 13
15: "wenquanyi_11pt.bdf", # native 15
16: "wenquanyi_12pt.bdf", # native 16
}
# Latin: LiberationSans for consistent Western metrics (KEY/BPM/Db).
# WQY 9pt Latin has uneven ink boxes (tiny Y, tall P) after cell packing.
SIZE_TO_LATIN_BDF = {
8: "LiberationSans-9pt.bdf",
9: "LiberationSans-9pt.bdf",
11: "LiberationSans-11pt.bdf",
12: "LiberationSans-12pt.bdf",
13: "LiberationSans-12pt.bdf",
15: "LiberationSans-12pt.bdf",
16: "LiberationSans-12pt.bdf",
}
def parse_bdf(path: str) -> dict[int, tuple[int, int, list[int]]]:
"""Return {unicode: (bbx_w, bbx_h, pixels row-major 0/1)}."""
glyphs: dict[int, tuple[int, int, list[int]]] = {}
with open(path, "r", encoding="latin1", errors="replace") as f:
lines = f.readlines()
i = 0
n = len(lines)
while i < n:
line = lines[i].strip()
if line.startswith("STARTCHAR"):
encoding = None
bbx = None
bitmap_rows = []
i += 1
while i < n:
s = lines[i].strip()
if s.startswith("ENCODING"):
encoding = int(s.split()[1])
elif s.startswith("BBX"):
parts = s.split()
bbx = (int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4]))
elif s == "BITMAP":
i += 1
while i < n and not lines[i].startswith("ENDCHAR"):
hexrow = lines[i].strip()
if hexrow:
bitmap_rows.append(hexrow)
i += 1
break
elif s.startswith("ENDCHAR"):
break
i += 1
if encoding is not None and bbx is not None and encoding >= 0:
bw, bh, xoff, yoff = bbx
pixels = []
row_bytes = (bw + 7) // 8
for row_hex in bitmap_rows:
hx = row_hex.strip()
if len(hx) % 2:
hx = "0" + hx
# pad/truncate to exact row byte count
need = row_bytes * 2
if len(hx) < need:
hx = hx + ("0" * (need - len(hx)))
elif len(hx) > need:
hx = hx[:need]
data = bytes.fromhex(hx)
for col in range(bw):
byte = data[col // 8]
bit = 7 - (col % 8)
pixels.append(1 if (byte >> bit) & 1 else 0)
while len(pixels) < bw * bh:
pixels.append(0)
glyphs[encoding] = (bw, bh, pixels[: bw * bh])
i += 1
return glyphs
_bdf_cache: dict[str, dict] = {}
def load_bdf(name: str) -> dict:
if name not in _bdf_cache:
path = os.path.join(WQY_DIR, name)
print(f"Loading BDF {path} ...")
_bdf_cache[name] = parse_bdf(path)
print(f" {len(_bdf_cache[name])} glyphs")
return _bdf_cache[name]
def ascii_cell_width(size: int) -> int:
"""Must match Drv LCD_GetAsciiMetrics / UI0902_ASCII_W glyph width."""
return {
8: 5, 9: 7, 11: 6, 12: 7, 13: 8, 15: 8, 16: 10, 24: 12, 28: 14,
}.get(size, max(3, size // 2))
def fit_to_cell(src_w, src_h, pixels, dst_w, dst_h) -> list[int]:
"""Place glyph into dst cell. Never crop ink — scale to fit.
Only add 1px margin when the cell is wide enough; tiny cells keep solid strokes.
"""
img = Image.new("L", (src_w, src_h), 0)
px = img.load()
for y in range(src_h):
for x in range(src_w):
if pixels[y * src_w + x]:
px[x, y] = 255
# 窄格≤8不要再缩边距否则 1/2 等竖笔画会被抽成断点
margin = 1 if dst_w >= 10 and dst_h >= 14 else 0
aw = max(1, dst_w - 2 * margin)
ah = max(1, dst_h - 2 * margin)
if src_w <= aw and src_h <= ah:
out = Image.new("L", (dst_w, dst_h), 0)
out.paste(img, (margin + (aw - src_w) // 2, margin + (ah - src_h) // 2))
else:
scale = min(aw / src_w, ah / src_h)
nw = max(1, int(round(src_w * scale)))
nh = max(1, int(round(src_h * scale)))
if nw > aw:
nw = aw
if nh > ah:
nh = ah
# BILINEAR 再二值化,比 NEAREST 更能保住 1/2 的连续笔画
resized = img.resize((nw, nh), Image.Resampling.BILINEAR)
out = Image.new("L", (dst_w, dst_h), 0)
out.paste(resized, (margin + (aw - nw) // 2, margin + (ah - nh) // 2))
bits = [1 if out.getpixel((x, y)) > 128 else 0 for y in range(dst_h) for x in range(dst_w)]
return bits
def glyph_pixels(ch: str, size: int, latin: bool = False) -> list[int]:
code = ord(ch)
# 调号用正常拉丁字母 bDb/Eb…不用手绘降号——降号竖笔过长、与 D 高低不一致
if latin or (32 <= code < 127):
bdf_name = SIZE_TO_LATIN_BDF.get(size, SIZE_TO_BDF[size])
gmap = load_bdf(bdf_name)
if code not in gmap:
gmap = load_bdf(SIZE_TO_BDF[size])
else:
gmap = load_bdf(SIZE_TO_BDF[size])
if code not in gmap:
if 32 <= code < 127:
w = ascii_cell_width(size)
return [0] * (w * size)
return [0] * (size * size)
bw, bh, pix = gmap[code]
if 32 <= code < 127:
return fit_to_cell(bw, bh, pix, ascii_cell_width(size), size)
return fit_to_cell(bw, bh, pix, size, size)
def cn_to_bytes_msb(pixels, width, height):
row_bytes = (width + 7) // 8
out = []
for row in range(height):
for bi in range(row_bytes):
val = 0
for bit in range(8):
col = bi * 8 + bit
if col < width and pixels[row * width + col]:
val |= 0x80 >> bit
out.append(val)
return out
def ascii_to_bytes_lsb(pixels, width, height):
row_bytes = (width + 7) // 8
out = []
for row in range(height):
for bi in range(row_bytes):
val = 0
for bit in range(8):
col = bi * 8 + bit
if col < width and pixels[row * width + col]:
val |= 0x01 << bit
out.append(val)
return out
def fmt_bytes(data):
return "{" + ",".join(f"0x{b:02X}" for b in data) + "}"
def parse_chs_tables(text):
tables = {}
for m in re.finditer(r"const char (CHS\d+Table)\[\]\[2\] = \{(.*?)\};", text, re.S):
name = m.group(1)
entries = []
comments = []
for line in m.group(2).splitlines():
cm = re.search(r"\{(0x[0-9A-Fa-f]{2})\s*,\s*(0x[0-9A-Fa-f]{2})\}", line)
if cm:
entries.append((int(cm.group(1), 16), int(cm.group(2), 16)))
cmt = re.search(r"//\s*(.+)", line)
comments.append(cmt.group(1).strip() if cmt else "")
tables[name] = list(zip(entries, comments))
return tables
def gbk_char(h, l):
return bytes([h, l]).decode("gbk", errors="replace")
def generate_chinese_array(entries, size):
lines = [f"const unsigned char Chinese_font_{size}[][{((size + 7)//8)*size}] = {{"]
for idx, ((h, l), comment) in enumerate(entries):
ch = gbk_char(h, l)
pixels = glyph_pixels(ch, size, latin=False)
data = cn_to_bytes_msb(pixels, size, size)
cmt = comment or ch
lines.append(f"{fmt_bytes(data)},/*\"{cmt}\",{idx}*/")
lines.append("};")
return "\n".join(lines)
def generate_ascii_array(size, array_name=None):
sizex = ascii_cell_width(size)
row_bytes = (sizex + 7) // 8
total = row_bytes * size
name = array_name or f"ascii_{size}{sizex}"
lines = [f"const unsigned char {name}[][{total}]={{"]
for num in range(95):
ch = chr(num + 32)
pixels = glyph_pixels(ch, size, latin=True)
data = ascii_to_bytes_lsb(pixels, sizex, size)
lines.append(f"{fmt_bytes(data)},/*\"{ch}\",{num}*/")
lines.append("};")
return name, "\n".join(lines)
def main():
if not os.path.isdir(WQY_DIR):
print("ERROR: missing", WQY_DIR)
sys.exit(1)
only = None
for a in sys.argv[1:]:
if a.startswith("--only="):
only = {int(x) for x in a.split("=", 1)[1].split(",") if x.strip()}
with open(HEADER_CN, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
tables = parse_chs_tables(text)
cn_sizes = [8, 9, 11, 12, 13, 15, 16]
if only:
cn_sizes = [s for s in cn_sizes if s in only]
for size in cn_sizes:
table_key = {
8: "CHS8Table", 9: "CHS9Table", 11: "CHS11Table", 12: "CHS12Table",
13: "CHS13Table", 15: "CHS15Table", 16: "CHS16Table",
}.get(size)
if not table_key or table_key not in tables:
print("skip cn", size)
continue
if size not in SIZE_TO_BDF:
print("no bdf map for", size)
continue
arr = generate_chinese_array(tables[table_key], size)
pat = rf"const unsigned char Chinese_font_{size}\[\]\[\d+\] =[\s\S]*?\}};"
if re.search(pat, text, re.S):
text = re.sub(pat, arr, text, count=1, flags=re.S)
print(f"Updated Chinese_font_{size}")
else:
print(f"WARN: Chinese_font_{size} not found")
with open(HEADER_CN, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
if "--ascii" not in sys.argv:
print("Skip ASCII (pass --ascii). Done CN.")
return
with open(HEADER_EN, "r", encoding="utf-8", errors="replace") as f:
eng = f.read()
ascii_targets = [
# Keep firmware symbol names even when cell width changed
(8, "ascii_84"), (9, "ascii_94"), (11, "ascii_115"), (12, "ascii_126"),
(13, "ascii_1306"), (15, "ascii_157"), (16, "ascii_1608"),
]
if only:
ascii_targets = [t for t in ascii_targets if t[0] in only]
for size, legacy in ascii_targets:
name, arr = generate_ascii_array(size, legacy)
pat = rf"const unsigned char {name}\[\]\[\d+\][\s\S]*?\}};"
if re.search(pat, eng, re.S):
eng = re.sub(pat, arr, eng, count=1, flags=re.S)
print(f"Updated {name}")
else:
auto = f"ascii_{size}{size//2}"
pat2 = rf"const unsigned char {auto}\[\]\[\d+\][\s\S]*?\}};"
if auto != name and re.search(pat2, eng, re.S):
eng = re.sub(pat2, arr, eng, count=1, flags=re.S)
print(f"Updated {auto}")
else:
eng = eng.rstrip() + "\n\n" + arr + "\n"
print(f"Appended {name}")
with open(HEADER_EN, "w", encoding="utf-8", newline="\n") as f:
f.write(eng)
print("Done WQY Bitmap Song.")
print("Rollback: git reset --hard checkpoint/misans-before-wqy")
if __name__ == "__main__":
main()