fc / src /export_img.py
Chuiguohuang's picture
deploy: 上传球队系统代码
6e876c1 verified
Raw History Blame Contribute Delete
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()