# -*- 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 percent_pixels(dst_w: int, dst_h: int) -> list[int]: """Clear 1bpp percent for status bar — Liberation small % is too sparse.""" # Classic 7-wide pattern (row-major), scaled/centered into dst cell src_w, src_h = 7, 9 src = [ 0,1,0,0,0,0,0, 1,0,1,0,0,1,0, 0,1,0,0,1,0,0, 0,0,0,1,0,0,0, 0,0,1,0,0,1,0, 0,1,0,0,1,0,1, 0,0,0,0,0,1,0, 0,0,0,0,0,0,0, 0,0,0,0,0,0,0, ] return fit_to_cell(src_w, src_h, src, dst_w, dst_h) 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. Native BDF cells (e.g. 12pt → 16×16) must paste 1:1. Adding a margin on an already-full cell forces 16→14 shrink and breaks Song-style strokes. """ if src_w == dst_w and src_h == dst_h: return list(pixels) 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 # Only pad when the source is smaller than the cell; never shrink for margin. 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)) else: scale = min(dst_w / src_w, dst_h / src_h) nw = max(1, min(dst_w, int(round(src_w * scale)))) nh = max(1, min(dst_h, int(round(src_h * scale)))) # BILINEAR 再二值化,比 NEAREST 更能保住 1px 连续笔画 resized = img.resize((nw, nh), Image.Resampling.BILINEAR) out = Image.new("L", (dst_w, dst_h), 0) out.paste(resized, ((dst_w - nw) // 2, (dst_h - 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) # 调号用正常拉丁字母 b(Db/Eb…);状态栏 % 用手绘清晰点阵 if latin and ch == "%" and size <= 12: return percent_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()