"""按页面规格渲染 466x466 预览图(真 MiSans 字体 + 真素材 + 圆形遮罩)。 支持 kind: rect / circle / image / label label 支持 recolor 标记 "#RRGGBB 文字#"(LVGL 的写法),以及 align/vcenter/vtop/lh """ import os, re, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PIL import Image, ImageDraw, ImageFont from paths import PROJ, INPUTS, FONTS, WORK ROOT = PROJ FONT_FILES = {'semibold': 'MiSans-Semibold.ttf', 'medium': 'MiSans-Medium.ttf', 'regular': 'MiSans-Regular.ttf'} W = H = 466 _fc = {} def _font_from_firmware(name): """从 inputs/target/vela_font.bin 里现取 MiSans(固件里就有这几个 ttf)。""" p = os.path.join(INPUTS, 'target', 'vela_font.bin') if not os.path.exists(p): return None try: from romfs import Romfs r = Romfs(p) except Exception: return None for path, ino in r.entries(): if path == name: d = os.path.join(WORK, 'fonts') os.makedirs(d, exist_ok=True) out = os.path.join(d, name) open(out, 'wb').write(r.read(ino)) return out return None def font(weight, size): k = (weight, size) if k in _fc: return _fc[k] name = FONT_FILES.get(weight, FONT_FILES['medium']) p = os.path.join(FONTS, name) if not os.path.exists(p): p = _font_from_firmware(name) or p if not os.path.exists(p): for alt in (r'C:\Windows\Fonts\msyh.ttc', r'C:\Windows\Fonts\msyhbd.ttc', r'C:\Windows\Fonts\arial.ttf'): if os.path.exists(alt): p = alt print(' [warn] %s 缺失,预览改用 %s(只影响预览缩略图)' % (name, os.path.basename(alt))) break _fc[k] = ImageFont.truetype(p, size) if os.path.exists(p) else ImageFont.load_default() return _fc[k] def _rgb(c): return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4)) def split_recolor(text, base): """LVGL 写法: '灰#ffffff 白#灰' - # + 6 位十六进制 -> 切到该颜色(该字符不显示) - 再次遇到单独的 # -> 结束着色,回到基础色(该字符不显示) """ runs, buf, cur, incol = [], '', base, False i = 0 while i < len(text): if text[i] == '#': if re.match(r'[0-9a-fA-F]{6}', text[i + 1:i + 7]): runs.append((cur, buf)); buf = '' cur = text[i + 1:i + 7].lower(); incol = True i += 7 continue if incol: runs.append((cur, buf)); buf = '' cur = base; incol = False i += 1 continue buf += text[i]; i += 1 runs.append((cur, buf)) return runs def wrap_runs(d, runs, f, maxw): """按像素宽度折行,返回 [[(color, text), ...], ...](尽量保持原换行)""" lines = [[]] for color, chunk in runs: for k, seg in enumerate(chunk.split('\n')): if k: lines.append([]) if not seg: continue cur = '' for ch in seg: if d.textlength(cur + ch, font=f) > maxw and cur: lines[-1].append((color, cur)) lines.append([]) cur = ch else: cur += ch if cur: lines[-1].append((color, cur)) return lines OVERFLOW = [] # (标签文本前 12 字, css_lh, 行数, 块高, 框高) def draw_label(img, d, it): f = font(it.get('weight', 'medium'), it['size']) runs = split_recolor(it['text'], it.get('color', 'ffffff')) \ if it.get('recolor') else [(it.get('color', 'ffffff'), it['text'])] asc, desc = f.getmetrics() natural = asc + desc lh = max(it.get('lh', 0), natural) # LVGL 用字体自身行高,取较大者预测 lines = wrap_runs(d, runs, f, it['w']) total = lh * len(lines) if total > it['h'] + 0.5: OVERFLOW.append((it['text'][:14].replace('\n', '/'), round(it.get('lh', 0), 2), len(lines), round(total, 1), it['h'], round(natural, 2))) if it.get('vcenter'): y = it['y'] + (it['h'] - total) / 2 elif it.get('vtop'): y = it['y'] else: y = it['y'] + (it['h'] - total) / 2 for ln in lines: wsum = sum(d.textlength(t, font=f) for _, t in ln) if it.get('align') == 'center': x = it['x'] + (it['w'] - wsum) / 2 elif it.get('align') == 'right': x = it['x'] + it['w'] - wsum else: x = it['x'] for color, t in ln: d.text((x, y), t, font=f, fill=_rgb(color) + (255,)) x += d.textlength(t, font=f) y += lh def render(items, out, mask=True, ring=False): img = Image.new('RGBA', (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(img) for it in items: k = it.get('kind') if k == 'rect': box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] r = it.get('radius', 0) (d.rounded_rectangle if r else d.rectangle)( box, radius=r, fill=_rgb(it['bg']) + (255,)) if r else d.rectangle( box, fill=_rgb(it['bg']) + (255,)) elif k == 'circle': box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] d.ellipse(box, fill=_rgb(it['bg']) + (255,)) elif k == 'image': p = it['src'] if os.path.exists(p): im = Image.open(p).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS) img.alpha_composite(im, (it['x'], it['y'])) elif k == 'label': draw_label(img, d, it) if mask: m = Image.new('L', (W, H), 0) ImageDraw.Draw(m).ellipse([0, 0, W - 1, H - 1], fill=255) img.putalpha(m) base = Image.new('RGB', (W, H), (0, 0, 0)) base.paste(img, (0, 0), img) if ring: ImageDraw.Draw(base).ellipse([0, 0, W - 1, H - 1], outline=(80, 80, 90)) base.save(out) return out if __name__ == '__main__': from pages import PAGES outs = [] for name, items in PAGES.items(): p = os.path.join(ROOT, '_out', f'ui_{name}.png') render(items, p) outs.append(p) print('wrote', p) # 拼一张总览图 cols = 3 rows = (len(outs) + cols - 1) // cols sheet = Image.new('RGB', (cols * (W + 12) + 12, rows * (H + 12) + 12), (28, 28, 32)) for i, p in enumerate(outs): im = Image.open(p) sheet.paste(im, (12 + (i % cols) * (W + 12), 12 + (i // cols) * (H + 12))) sp = os.path.join(ROOT, '_out', 'ui_all.png') sheet.save(sp) print('wrote', sp, sheet.size)