#!/usr/bin/env python3 """ GUI tool: batch generate single-character bitmap .bin files for LVGL watchface. Generates _device.bin (v8 header) and _sim.bin (v9 header) for each character. """ from __future__ import annotations import os import sys import tkinter as tk from tkinter import ttk, font as tkfont, filedialog, colorchooser from pathlib import Path from typing import Optional from PIL import Image, ImageDraw, ImageFont, ImageTk sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "watchface" / "tools")) from LVGLImage import LVGLImage, ColorFormat, CompressMethod from LVGLImage_v8 import write_bin_v8 CHAR_NAME_MAP = { ":": "colon", ".": "dot", ",": "comma", " ": "space", "-": "dash", "_": "underscore", "/": "slash", "\\": "backslash", "+": "plus", "!": "exclaim", "?": "question", "%": "percent", "'": "apos", '"': "quote", "@": "at", "#": "hash", "(": "lparen", ")": "rparen", "&": "ampersand", "*": "asterisk", } def char_to_filename(c: str) -> str: if c in CHAR_NAME_MAP: return CHAR_NAME_MAP[c] if c.isalnum(): return c return f"char_{ord(c):04d}" def pil_rgba_to_lvgl_argb8888(pil_bytes: bytes) -> bytes: out = bytearray() for i in range(0, len(pil_bytes), 4): out.append(pil_bytes[i + 2]) out.append(pil_bytes[i + 1]) out.append(pil_bytes[i + 0]) out.append(pil_bytes[i + 3]) return bytes(out) def render_char(char: str, font_path: str, font_size: int, fg_color: tuple[int, int, int], img_w: int, img_h: int, halign: str, stroke_width: int = 0, stroke_color: tuple[int, int, int] = (0, 0, 0), shadow_enabled: bool = False, shadow_x: int = 0, shadow_y: int = 0, shadow_color: tuple[int, int, int] = (0, 0, 0)) -> Image.Image: canvas = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) ft = ImageFont.truetype(font_path, font_size) bbox = draw.textbbox((0, 0), char, font=ft) tw = bbox[2] - bbox[0] th = bbox[3] - bbox[1] if halign == "left": x = 0 elif halign == "right": x = img_w - tw else: x = (img_w - tw) // 2 y = (img_h - th) // 2 origin_x = x - bbox[0] origin_y = y - bbox[1] if shadow_enabled: draw.text((origin_x + shadow_x, origin_y + shadow_y), char, font=ft, fill=(*shadow_color, 255), stroke_width=stroke_width, stroke_fill=(*shadow_color, 255)) draw.text((origin_x, origin_y), char, font=ft, fill=(*fg_color, 255), stroke_width=stroke_width, stroke_fill=(*stroke_color, 255)) return canvas def save_lvgl_bins(img: Image.Image, output_dir: Path, basename: str): w, h = img.size raw = pil_rgba_to_lvgl_argb8888(img.tobytes()) lvgl_img = LVGLImage().set_data(ColorFormat.ARGB8888, w, h, raw) dev_path = output_dir / f"{basename}_device.bin" write_bin_v8(lvgl_img, str(dev_path)) sim_path = output_dir / f"{basename}_sim.bin" lvgl_img.to_bin(str(sim_path), compress=CompressMethod.NONE) class DigitGeneratorApp: def __init__(self): self.root = tk.Tk() self.root.title("Digit Bitmap Generator") self.root.minsize(1000, 600) self._build_ui() self._populate_fonts() self._update_preview() # ─── UI Build ────────────────────────────────────────── def _build_ui(self): main = ttk.Frame(self.root, padding=12) main.pack(fill="both", expand=True) left = ttk.Frame(main) left.pack(side="left", fill="y", padx=(0, 12)) right = ttk.Frame(main) right.pack(side="left", fill="both", expand=True) row = 0 # ── Font ── ttk.Label(left, text="字体:").grid(row=row, column=0, sticky="w", pady=(0, 2)) self.font_var = tk.StringVar() self.font_combo = ttk.Combobox(left, textvariable=self.font_var, width=30, state="readonly") self.font_combo.grid(row=row, column=1, sticky="ew", padx=(4, 0)) ttk.Button(left, text="浏览...", command=self._browse_font).grid(row=row, column=2, padx=(4, 0)) row += 1 # ── Font size ── ttk.Label(left, text="字号:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.size_var = tk.IntVar(value=64) self.size_spin = ttk.Spinbox(left, from_=8, to=300, textvariable=self.size_var, width=8) self.size_spin.grid(row=row, column=1, sticky="w", padx=(4, 0)) row += 1 # ── Color ── ttk.Label(left, text="文字颜色:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.color_var = tk.StringVar(value="#FFFFFF") self.color_btn = tk.Canvas(left, width=28, height=24, highlightthickness=1, highlightbackground="#888", cursor="hand2") self.color_btn.grid(row=row, column=1, sticky="w", padx=(4, 0)) self.color_btn.bind("", lambda e: self._pick_color()) self._update_color_swatch() row += 1 # ── Background color (preview only) ── ttk.Label(left, text="背景颜色:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.bg_color_var = tk.StringVar(value="#262626") self.bg_color_btn = tk.Canvas(left, width=28, height=24, highlightthickness=1, highlightbackground="#888", cursor="hand2") self.bg_color_btn.grid(row=row, column=1, sticky="w", padx=(4, 0)) self.bg_color_btn.bind("", lambda e: self._pick_bg_color()) self._update_bg_swatch() row += 1 # ── Stroke ── ttk.Label(left, text="描边宽度:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.stroke_width_var = tk.IntVar(value=0) ttk.Spinbox(left, from_=0, to=20, textvariable=self.stroke_width_var, width=8).grid(row=row, column=1, sticky="w", padx=(4, 0)) row += 1 ttk.Label(left, text="描边颜色:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.stroke_color_var = tk.StringVar(value="#000000") self.stroke_color_btn = tk.Canvas(left, width=28, height=24, highlightthickness=1, highlightbackground="#888", cursor="hand2") self.stroke_color_btn.grid(row=row, column=1, sticky="w", padx=(4, 0)) self.stroke_color_btn.bind("", lambda e: self._pick_stroke_color()) self._update_stroke_swatch() row += 1 # ── Shadow ── self.shadow_enabled_var = tk.BooleanVar(value=False) ttk.Checkbutton(left, text="启用阴影", variable=self.shadow_enabled_var).grid(row=row, column=0, columnspan=2, sticky="w", pady=(4, 2)) row += 1 ttk.Label(left, text="阴影偏移 X:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.shadow_x_var = tk.IntVar(value=2) ttk.Spinbox(left, from_=-50, to=50, textvariable=self.shadow_x_var, width=8).grid(row=row, column=1, sticky="w", padx=(4, 0)) row += 1 ttk.Label(left, text="阴影偏移 Y:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.shadow_y_var = tk.IntVar(value=2) ttk.Spinbox(left, from_=-50, to=50, textvariable=self.shadow_y_var, width=8).grid(row=row, column=1, sticky="w", padx=(4, 0)) row += 1 ttk.Label(left, text="阴影颜色:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.shadow_color_var = tk.StringVar(value="#000000") self.shadow_color_btn = tk.Canvas(left, width=28, height=24, highlightthickness=1, highlightbackground="#888", cursor="hand2") self.shadow_color_btn.grid(row=row, column=1, sticky="w", padx=(4, 0)) self.shadow_color_btn.bind("", lambda e: self._pick_shadow_color()) self._update_shadow_swatch() row += 1 # ── Image size ── ttk.Label(left, text="位图尺寸:").grid(row=row, column=0, sticky="w", pady=(4, 2)) szf = ttk.Frame(left) szf.grid(row=row, column=1, sticky="w", padx=(4, 0)) ttk.Label(szf, text="宽").pack(side="left") self.w_var = tk.IntVar(value=64) ttk.Spinbox(szf, from_=8, to=500, textvariable=self.w_var, width=6).pack(side="left", padx=(2, 8)) ttk.Label(szf, text="高").pack(side="left") self.h_var = tk.IntVar(value=80) ttk.Spinbox(szf, from_=8, to=500, textvariable=self.h_var, width=6).pack(side="left", padx=(2, 0)) row += 1 # ── Horizontal align ── ttk.Label(left, text="水平对齐:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.halign_var = tk.StringVar(value="center") haf = ttk.Frame(left) haf.grid(row=row, column=1, sticky="w", padx=(4, 0)) for text, val in [("居左", "left"), ("居中", "center"), ("居右", "right")]: ttk.Radiobutton(haf, text=text, variable=self.halign_var, value=val).pack(side="left", padx=(0, 6)) row += 1 # ── Characters ── ttk.Label(left, text="文字:").grid(row=row, column=0, sticky="nw", pady=(4, 2)) self.chars_text = tk.Text(left, width=28, height=4) self.chars_text.grid(row=row, column=1, sticky="ew", padx=(4, 0)) row += 1 # quick buttons qf = ttk.Frame(left) qf.grid(row=row, column=0, columnspan=3, pady=(4, 0)) ttk.Button(qf, text="0-9:", command=lambda: self._insert_chars("0123456789:")).pack(side="left", padx=(0, 4)) ttk.Button(qf, text="0-9", command=lambda: self._insert_chars("0123456789")).pack(side="left", padx=(0, 4)) ttk.Button(qf, text="A-Z", command=lambda: self._insert_chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")).pack(side="left", padx=(0, 4)) ttk.Button(qf, text="a-z", command=lambda: self._insert_chars("abcdefghijklmnopqrstuvwxyz")).pack(side="left", padx=(0, 4)) row += 1 # ── Output dir ── ttk.Label(left, text="输出目录:").grid(row=row, column=0, sticky="w", pady=(4, 2)) self.outdir_var = tk.StringVar( value=str(Path(__file__).resolve().parent.parent / "watchface" / "fprj" / "app" / "images" / "digits") ) ttk.Entry(left, textvariable=self.outdir_var, width=30).grid(row=row, column=1, sticky="ew", padx=(4, 0)) ttk.Button(left, text="浏览...", command=self._browse_outdir).grid(row=row, column=2, padx=(4, 0)) row += 1 # ── Generate ── self.gen_btn = ttk.Button(left, text="生成位图", command=self._generate) self.gen_btn.grid(row=row, column=0, columnspan=3, pady=(12, 0)) row += 1 self.status_var = tk.StringVar() ttk.Label(left, textvariable=self.status_var, foreground="#666").grid(row=row, column=0, columnspan=3, pady=(4, 0)) row += 1 # ── Preview ── ttk.Label(right, text="预览", font=("", 11, "bold")).pack(anchor="nw") self.preview_canvas = tk.Canvas(right, bg="#1a1a1a", highlightthickness=0) self.preview_canvas.pack(fill="both", expand=True) # ── Bind changes ── for v in [self.font_var, self.size_var, self.w_var, self.h_var, self.halign_var, self.color_var, self.bg_color_var, self.stroke_width_var, self.stroke_color_var, self.shadow_enabled_var, self.shadow_x_var, self.shadow_y_var, self.shadow_color_var]: v.trace_add("write", lambda *_: self._update_preview()) self.chars_text.bind("", lambda e: self._update_preview()) def _insert_chars(self, chars: str): self.chars_text.insert("end", chars) self._update_preview() def _populate_fonts(self): families = sorted(tkfont.families()) self.font_combo["values"] = families if "Microsoft YaHei" in families: self.font_var.set("Microsoft YaHei") elif "SimHei" in families: self.font_var.set("SimHei") elif families: self.font_var.set(families[0]) def _browse_font(self): path = filedialog.askopenfilename( title="选择字体文件", filetypes=[("TrueType/OpenType", "*.ttf *.otf"), ("All", "*.*")] ) if path: self.font_combo["state"] = "normal" self.font_var.set(path) self.font_combo["state"] = "readonly" def _pick_color(self): rgb, hx = colorchooser.askcolor(title="选择颜色", color=self.color_var.get()) if rgb: self.color_var.set(hx) self._update_color_swatch() self._update_preview() def _update_color_swatch(self): if hasattr(self, "color_btn"): hexc = self.color_var.get().lstrip("#") r, g, b = int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) self.color_btn.delete("all") self.color_btn.create_rectangle(0, 0, 28, 24, fill=f"#{r:02x}{g:02x}{b:02x}", outline="") def _pick_bg_color(self): rgb, hx = colorchooser.askcolor(title="选择背景颜色", color=self.bg_color_var.get()) if rgb: self.bg_color_var.set(hx) self._update_bg_swatch() self._update_preview() def _update_bg_swatch(self): if hasattr(self, "bg_color_btn"): hexc = self.bg_color_var.get().lstrip("#") r, g, b = int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) self.bg_color_btn.delete("all") self.bg_color_btn.create_rectangle(0, 0, 28, 24, fill=f"#{r:02x}{g:02x}{b:02x}", outline="") def _pick_stroke_color(self): rgb, hx = colorchooser.askcolor(title="选择描边颜色", color=self.stroke_color_var.get()) if rgb: self.stroke_color_var.set(hx) self._update_stroke_swatch() self._update_preview() def _update_stroke_swatch(self): if hasattr(self, "stroke_color_btn"): hexc = self.stroke_color_var.get().lstrip("#") r, g, b = int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) self.stroke_color_btn.delete("all") self.stroke_color_btn.create_rectangle(0, 0, 28, 24, fill=f"#{r:02x}{g:02x}{b:02x}", outline="") def _pick_shadow_color(self): rgb, hx = colorchooser.askcolor(title="选择阴影颜色", color=self.shadow_color_var.get()) if rgb: self.shadow_color_var.set(hx) self._update_shadow_swatch() self._update_preview() def _update_shadow_swatch(self): if hasattr(self, "shadow_color_btn"): hexc = self.shadow_color_var.get().lstrip("#") r, g, b = int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) self.shadow_color_btn.delete("all") self.shadow_color_btn.create_rectangle(0, 0, 28, 24, fill=f"#{r:02x}{g:02x}{b:02x}", outline="") def _parse_color(self) -> tuple[int, int, int]: hexc = self.color_var.get().lstrip("#") return int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) def _parse_bg_color(self) -> tuple[int, int, int]: hexc = self.bg_color_var.get().lstrip("#") return int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) def _parse_stroke_color(self) -> tuple[int, int, int]: hexc = self.stroke_color_var.get().lstrip("#") return int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) def _parse_shadow_color(self) -> tuple[int, int, int]: hexc = self.shadow_color_var.get().lstrip("#") return int(hexc[:2], 16), int(hexc[2:4], 16), int(hexc[4:6], 16) def _browse_outdir(self): path = filedialog.askdirectory(title="选择输出目录") if path: self.outdir_var.set(path) # ── Preview ── def _get_chars(self) -> list[str]: raw = self.chars_text.get("1.0", "end-1c") result = [] for ch in raw: if ch.strip() or ch == " ": result.append(ch) return result def _resolve_font_path(self) -> Optional[str]: val = self.font_var.get().strip() if not val: return None if os.path.isfile(val): return val try: tf = ImageFont.truetype(val, 12) return val except Exception: return None def _render_preview_images(self) -> list[Image.Image]: chars = self._get_chars() if not chars: return [] font_path = self._resolve_font_path() if not font_path: return [] fg = self._parse_color() w = self.w_var.get() h = self.h_var.get() size = self.size_var.get() halign = self.halign_var.get() stroke_width = self.stroke_width_var.get() stroke_color = self._parse_stroke_color() shadow_enabled = self.shadow_enabled_var.get() shadow_x = self.shadow_x_var.get() shadow_y = self.shadow_y_var.get() shadow_color = self._parse_shadow_color() images = [] for ch in chars: img = render_char(ch, font_path, size, fg, w, h, halign, stroke_width, stroke_color, shadow_enabled, shadow_x, shadow_y, shadow_color) images.append(img) return images def _update_preview(self): images = self._render_preview_images() self.preview_canvas.delete("all") if not images: return gap = 8 thumb_h = 100 thumbs = [] total_w = 0 bg_rgb = self._parse_bg_color() for img in images: ratio = thumb_h / img.height tw = int(img.width * ratio) thumb = img.resize((tw, thumb_h), Image.LANCZOS) bg_preview = Image.new("RGBA", thumb.size, (*bg_rgb, 255)) bg_preview.paste(thumb, (0, 0), thumb) thumbs.append(bg_preview) total_w += tw + gap total_w -= gap canvas_img = Image.new("RGBA", (total_w, thumb_h), (*bg_rgb, 255)) x = 0 for thumb in thumbs: canvas_img.paste(thumb, (x, 0), thumb) x += thumb.width + gap cw = self.preview_canvas.winfo_width() or 400 ch = self.preview_canvas.winfo_height() or 200 if total_w > cw: scale = cw / total_w new_w = int(cw) new_h = int(thumb_h * scale) canvas_img = canvas_img.resize((new_w, new_h), Image.LANCZOS) self._preview_tk = ImageTk.PhotoImage(canvas_img) self.preview_canvas.create_image(cw // 2, ch // 2, image=self._preview_tk, anchor="center") # ── Generate ── def _generate(self): chars = self._get_chars() if not chars: self.status_var.set("请先输入文字") return font_path = self._resolve_font_path() if not font_path: self.status_var.set("无效字体") return outdir = Path(self.outdir_var.get()) outdir.mkdir(parents=True, exist_ok=True) fg = self._parse_color() w = self.w_var.get() h = self.h_var.get() size = self.size_var.get() halign = self.halign_var.get() stroke_width = self.stroke_width_var.get() stroke_color = self._parse_stroke_color() shadow_enabled = self.shadow_enabled_var.get() shadow_x = self.shadow_x_var.get() shadow_y = self.shadow_y_var.get() shadow_color = self._parse_shadow_color() generated = [] for ch in chars: img = render_char(ch, font_path, size, fg, w, h, halign, stroke_width, stroke_color, shadow_enabled, shadow_x, shadow_y, shadow_color) basename = char_to_filename(ch) save_lvgl_bins(img, outdir, basename) generated.append(basename) self.status_var.set(f"已生成 {len(generated)} 个位图: {', '.join(generated)}") def run(self): self.root.mainloop() if __name__ == "__main__": DigitGeneratorApp().run()