"""把榜单画成品牌化 PNG,供「一键导出发微信群」。仅依赖 Pillow。 绘图与数据解耦:调用方传入已排好序的 boards(每个榜单 = 标题 + 行列表), 本模块只负责排版渲染,不碰数据库。 """ from __future__ import annotations import io import os from typing import Optional from PIL import Image, ImageDraw, ImageFont from .config import WWW_DIR # 品牌色(取自队徽:藏青 / 金 / 红) NAVY_DEEP = (10, 23, 48) NAVY = (17, 37, 74) PANEL = (24, 48, 92) GOLD = (212, 175, 55) GOLD2 = (235, 203, 107) CREAM = (242, 236, 221) BLUE = (61, 165, 217) MUTED = (157, 176, 208) LINE = (40, 70, 120) # 候选 CJK 字体(Windows / Linux) _FONT_DIRS = [r"C:\Windows\Fonts", "/usr/share/fonts", os.path.expanduser("~/.fonts")] _FONT_FILES = [ "msyhbd.ttc", "msyh.ttc", "msyh.ttf", "simhei.ttf", "simsun.ttc", "NotoSansCJK-Regular.ttc", "NotoSansSC-Regular.otf", "DengXian.ttf", ] def _font_path() -> Optional[str]: for d in _FONT_DIRS: for name in _FONT_FILES: p = os.path.join(d, name) if os.path.exists(p): return p return None _FONT_PATH = _font_path() def _font(size: int) -> ImageFont.FreeTypeFont: if _FONT_PATH: try: return ImageFont.truetype(_FONT_PATH, size) except OSError: pass return ImageFont.load_default() def _crest(size: int) -> Optional[Image.Image]: """加载队徽并裁成圆形;失败则返回 None(不阻断导出)。""" path = WWW_DIR / "crest.jpg" if not path.exists(): return None try: im = Image.open(path).convert("RGBA").resize((size, size)) mask = Image.new("L", (size, size), 0) ImageDraw.Draw(mask).ellipse((0, 0, size, size), fill=255) im.putalpha(mask) return im except Exception: return None def render_leaderboard_png( team_name: str, season_label: str, date_str: str, boards: list[tuple[str, list[tuple[str, str, str]]]], ) -> bytes: """渲染榜单 PNG。 boards: [(标题, [(名次, 名字, 数值), ...]), ...],建议 4 个榜单(2×2 网格)。 返回 PNG 字节流。 """ W = 920 margin = 40 header_h = 132 col_gap = 28 row_title_h = 46 line_h = 34 max_rows = 8 col_w = (W - margin * 2 - col_gap) // 2 rows_per_board = min( max_rows, max((len(rows) for _, rows in boards), default=0) ) board_h = row_title_h + line_h * max(1, rows_per_board) + 16 n_board_rows = (len(boards) + 1) // 2 H = header_h + n_board_rows * (board_h + col_gap) + margin img = Image.new("RGB", (W, H), NAVY_DEEP) d = ImageDraw.Draw(img) f_team = _font(40) f_sub = _font(18) f_btitle = _font(22) f_row = _font(20) f_rank = _font(20) # ---- 顶部渐变带 + 队名 ---- d.rectangle((0, 0, W, header_h), fill=NAVY) d.line((0, header_h, W, header_h), fill=GOLD, width=2) x_text = margin crest = _crest(84) if crest is not None: img.paste(crest, (margin, 24), crest) x_text = margin + 84 + 20 d.text((x_text, 30), team_name, font=f_team, fill=GOLD2) d.text((x_text + 2, 84), f"{season_label} · 榜单 · {date_str}", font=f_sub, fill=MUTED) # ---- 2×2 榜单 ---- medals = {1: "①", 2: "②", 3: "③"} for idx, (title, rows) in enumerate(boards): r, c = divmod(idx, 2) x = margin + c * (col_w + col_gap) y = header_h + col_gap + r * (board_h + col_gap) # 卡片底 d.rounded_rectangle((x, y, x + col_w, y + board_h), radius=16, fill=NAVY) d.rounded_rectangle( (x, y, x + col_w, y + board_h), radius=16, outline=LINE, width=1 ) # 标题 d.text((x + 18, y + 12), title, font=f_btitle, fill=GOLD2) d.line((x + 18, y + 44, x + col_w - 18, y + 44), fill=LINE, width=1) # 行 ry = y + row_title_h + 6 if not rows: d.text((x + 18, ry), "暂无数据", font=f_row, fill=MUTED) for rank, name, value in rows[:max_rows]: rk = medals.get(int(rank) if rank.isdigit() else 0, rank) rk_color = GOLD if rank in ("1", "2", "3") else MUTED d.text((x + 18, ry), rk, font=f_rank, fill=rk_color) d.text((x + 56, ry), name, font=f_row, fill=CREAM) vw = d.textlength(value, font=f_row) d.text((x + col_w - 18 - vw, ry), value, font=f_row, fill=GOLD2) ry += line_h buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue()