361 lines
13 KiB
Python
361 lines
13 KiB
Python
|
|
# -*- 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."""
|
||
|
|
return {
|
||
|
|
8: 5, 9: 7, 11: 6, 12: 7, 13: 8, 15: 8, 16: 8, 24: 12, 28: 14,
|
||
|
|
}.get(size, max(3, size // 2))
|
||
|
|
|
||
|
|
|
||
|
|
def musical_flat_pixels(dst_w: int, dst_h: int) -> list[int]:
|
||
|
|
"""Hand flat for key names (Db/Eb…): matches capital stem weight better than Latin 'b'."""
|
||
|
|
# Stem on left, bowl mid-height; no descender so it sits beside 'D' cleanly.
|
||
|
|
out = [0] * (dst_w * dst_h)
|
||
|
|
# vertical stem
|
||
|
|
x0 = max(0, dst_w // 2 - 2)
|
||
|
|
top = max(1, dst_h // 8)
|
||
|
|
bot = dst_h - 2
|
||
|
|
for y in range(top, bot):
|
||
|
|
out[y * dst_w + x0] = 1
|
||
|
|
# bowl (open to the right of stem)
|
||
|
|
mid = top + (bot - top) // 2
|
||
|
|
bowl_r = min(dst_w - 1, x0 + max(2, dst_w // 2))
|
||
|
|
for y in range(top + 1, mid + 1):
|
||
|
|
out[y * dst_w + bowl_r] = 1
|
||
|
|
for x in range(x0, bowl_r + 1):
|
||
|
|
out[top * dst_w + x] = 1
|
||
|
|
out[mid * dst_w + x] = 1
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
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, then center."""
|
||
|
|
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
|
||
|
|
|
||
|
|
if src_w <= dst_w and src_h <= dst_h:
|
||
|
|
out = Image.new("L", (dst_w, dst_h), 0)
|
||
|
|
out.paste(img, ((dst_w - src_w) // 2, (dst_h - src_h) // 2))
|
||
|
|
return [1 if out.getpixel((x, y)) > 128 else 0 for y in range(dst_h) for x in range(dst_w)]
|
||
|
|
|
||
|
|
# Oversize: aspect-fit (keep full glyph; 1px right-edge must not be cropped)
|
||
|
|
scale = min(dst_w / src_w, dst_h / src_h)
|
||
|
|
nw = max(1, int(round(src_w * scale)))
|
||
|
|
nh = max(1, int(round(src_h * scale)))
|
||
|
|
if nw > dst_w:
|
||
|
|
nw = dst_w
|
||
|
|
if nh > dst_h:
|
||
|
|
nh = dst_h
|
||
|
|
resized = img.resize((nw, nh), Image.Resampling.NEAREST)
|
||
|
|
out = Image.new("L", (dst_w, dst_h), 0)
|
||
|
|
out.paste(resized, ((dst_w - nw) // 2, (dst_h - nh) // 2))
|
||
|
|
return [1 if out.getpixel((x, y)) > 128 else 0 for y in range(dst_h) for x in range(dst_w)]
|
||
|
|
|
||
|
|
|
||
|
|
def glyph_pixels(ch: str, size: int, latin: bool = False) -> list[int]:
|
||
|
|
code = ord(ch)
|
||
|
|
# Key accidentals: Latin 'b' reads as letter-b and looks mismatched next to 'D'
|
||
|
|
if latin and ch == "b" and size >= 11:
|
||
|
|
return musical_flat_pixels(ascii_cell_width(size), size)
|
||
|
|
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()
|