Download src/export_img.py from sidaier/fc: direct link, hf CLI and curl.
- Browser
- Download file 4.62 kB
-
https://huggingface.co/spaces/sidaier/fc/resolve/main/src/export_img.py
- Command line
-
hf download hf://spaces/sidaier/fc/src/export_img.py
-
curl -L -o export_img.py https://huggingface.co/spaces/sidaier/fc/resolve/main/src/export_img.py
4.62 kB
| """把榜单画成品牌化 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() | |