src/bots/bridge_core/card_renderer.py
Rama main · réplica de lectura del repositorio original.
"""Shared PNG card rendering for bot bridge channels.
Renders a title/subtitle/lines card to a PNG, using Pillow when available and
falling back to system fonts. Callers should treat send failure as a
degradation path and continue with plain text.
卡面文本是中文为主的展示文本,因此字体解析必须拿到**真正含 CJK 字形**的
字体:找不到就明确失败(由调用方降级为纯文本),绝不静默使用只含拉丁字形
的默认字体生成满屏 ``□□□□`` 的图片。
"""
from __future__ import annotations
import hashlib
import logging
import time
from pathlib import Path
from typing import Iterable
logger = logging.getLogger("trpg.bridge")
BRAND_NAME = "DiceFrame"
BRAND_SLOGAN = "把一句话,掷成冒险"
BRAND_FOOTER = f"{BRAND_NAME} · {BRAND_SLOGAN}"
# 无 CJK 字体时每次渲染都会失败,warning 只记一次,避免刷日志。
_CJK_FONT_WARNING_EMITTED = False
def _font_paths() -> list[str]:
"""Candidate CJK font paths (Windows / Linux / macOS), most preferred first."""
return [
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\msyh.ttf",
r"C:\Windows\Fonts\simhei.ttf",
r"C:\Windows\Fonts\simsun.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/arphic/uming.ttc",
"/usr/share/fonts/truetype/arphic/ukai.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
]
# 发行版把 CJK 字体装到别的目录时,显式路径表会全部落空;这里再按文件名
# 扫一遍标准字体根目录兜底(只认名字带 CJK 特征的字体,保持可预测且廉价)。
_FONT_ROOTS = (
"/usr/share/fonts",
"/usr/local/share/fonts",
"/System/Library/Fonts",
"/Library/Fonts",
)
_CJK_NAME_HINTS = ("cjk", "noto", "wqy", "hei", "song", "ming", "kai", "yahei", "pingfang", "sourcehan", "hanazono")
def _scan_font_files() -> list[str]:
"""Name-filtered scan of standard font roots (deterministic order)."""
found: list[str] = []
for root in _FONT_ROOTS:
base = Path(root)
if not base.is_dir():
continue
for pattern in ("**/*.ttc", "**/*.otf", "**/*.ttf"):
try:
candidates = sorted(base.glob(pattern))
except OSError:
continue
for candidate in candidates:
name = candidate.name.casefold()
if any(hint in name for hint in _CJK_NAME_HINTS):
found.append(str(candidate))
return found
def _cjk_font_candidates() -> list[str]:
"""Font candidates tried by :func:`_load_font` (explicit paths first, then scan).
Single seam for tests/hosts that need deterministic font resolution.
"""
ordered: list[str] = []
for path in [*_font_paths(), *_scan_font_files()]:
if path not in ordered:
ordered.append(path)
return ordered
def _font_supports_cjk(font) -> bool:
"""Whether ``font`` really draws CJK glyphs instead of ``.notdef`` boxes.
只检查 ``getmask().getbbox()`` 不够:只含拉丁字形的字体(包括 Pillow 的
默认字体)会为中文画出 .notdef 方框,bbox 依然非空。这里再比较同长度
中文与私用区码点的位图——只含拉丁字形时两者完全相同(都是方框),
真正含 CJK 字形的字体则不同。
"""
try:
cjk_mask = font.getmask("中文测试")
if cjk_mask.getbbox() is None:
return False
return bytes(cjk_mask) != bytes(font.getmask("\ue003\ue003\ue003\ue003"))
except Exception:
return False
def _load_font(size: int):
"""Load a CJK-capable font at ``size``.
Raises :class:`RuntimeError` when no usable CJK font exists on this host;
callers already treat card rendering as a degradation path (plain text).
"""
from PIL import ImageFont
for path in _cjk_font_candidates():
if not Path(path).exists():
continue
try:
font = ImageFont.truetype(path, size=size)
except Exception:
continue
if _font_supports_cjk(font):
logger.debug("Bot card font loaded: %s", path)
return font
global _CJK_FONT_WARNING_EMITTED
if not _CJK_FONT_WARNING_EMITTED:
_CJK_FONT_WARNING_EMITTED = True
logger.warning(
"No usable CJK font found for bot card rendering; "
"install a CJK font (e.g. fonts-noto-cjk on Linux)",
)
# 绝不回退到 Pillow 默认字体:它不含 CJK 字形,会渲染出满屏 □□□□。
raise RuntimeError("No usable CJK font found for bot card rendering")
def render_card_png(
out_dir: Path,
*,
title: str,
subtitle: str = "",
lines: Iterable[str] = (),
footer: str = "",
hint: list[tuple[str, str]] | None = None,
) -> Path:
"""渲染一张适合 QQ 发送的 PNG 卡片,返回文件路径。"""
from PIL import Image, ImageDraw
out_dir.mkdir(parents=True, exist_ok=True)
title = str(title or BRAND_NAME)
subtitle = str(subtitle or "")
# 保留空串作为段落分隔标记(渲染时段间留空行);丢弃只含空白的行
# 元素可为 str(正文)或 (text, True)(小字号辅助行,如建议项)
raw_lines: list[tuple[str, bool]] = []
for line in lines:
if isinstance(line, tuple):
text, small = line
else:
text, small = line, False
text = str(text)
if text == "" or text.strip():
raw_lines.append((text, bool(small)))
footer = str(footer or "")
# 可选行动两列:每对 (左, 右),渲染在正文最下方、footer 前
hint_pairs: list[tuple[str, str]] = []
for pair in hint or []:
if isinstance(pair, (list, tuple)) and len(pair) >= 2:
left = str(pair[0] or "").strip()
right = str(pair[1] or "").strip()
if left or right:
hint_pairs.append((left, right))
width = 660
pad = 30
title_font = _load_font(29)
sub_font = _load_font(19)
body_font = _load_font(21)
small_font = _load_font(17)
foot_font = _load_font(16)
line_gap = 10
paragraph_gap = 18
measure = Image.new("RGB", (10, 10))
measure_draw = ImageDraw.Draw(measure)
content_width = width - pad * 2
indent_px = measure_draw.textbbox((0, 0), " ", font=body_font)[2]
wrapped: list[tuple[str, bool]] = []
for text, small in raw_lines:
if text == "":
wrapped.append(("", False)) # 段落分隔
else:
font = small_font if small else body_font
wrapped.extend(
(w, small)
for w in _wrap_by_pixel(
measure_draw,
text,
font,
content_width,
first_line_max_width=content_width - indent_px,
)
)
title_height = 40
subtitle_height = 28 if subtitle else 0
body_height = sum(
paragraph_gap if ln == "" else ((19 if sm else 23) + line_gap)
for ln, sm in wrapped
) or (23 + line_gap)
hint_line_height = 19 + line_gap
hint_block_height = 0
if hint_pairs:
hint_block_height = paragraph_gap + hint_line_height + len(hint_pairs) * hint_line_height
footer_height = 40 if footer else 14
height = pad * 2 + title_height + subtitle_height + 22 + body_height + hint_block_height + footer_height
height = max(240, min(6000, height))
accent = _accent_for(title + subtitle)
image = Image.new("RGB", (width, height), accent["bg"])
draw = ImageDraw.Draw(image)
_paint_background(draw, width, height, accent)
card_margin = 12
card_box = (card_margin, card_margin, width - card_margin, height - card_margin)
draw.rounded_rectangle(card_box, radius=18, fill=accent["panel"], outline=accent["outline"], width=1)
draw.line((pad, pad + 42, width - pad, pad + 42), fill=accent["line"], width=1)
draw.text((pad, pad - 4), _fit_by_pixel(measure_draw, title, title_font, content_width), font=title_font, fill=accent["title"])
y = pad + title_height
if subtitle:
draw.text((pad, y), _fit_by_pixel(measure_draw, subtitle, sub_font, content_width), font=sub_font, fill=accent["muted"])
y += subtitle_height
y += 16
paragraph_start = True
for index, (line, is_small) in enumerate(wrapped[:120]):
if line == "":
y += paragraph_gap # 段落分隔:段间留空行
paragraph_start = True
continue
font = small_font if is_small else body_font
if index and line.lstrip().startswith(("1.", "2.", "3.", "4.", "5.", "6.", "7.", "- ", "· ")):
y += 2
draw.text((pad + (indent_px if paragraph_start else 0), y), line, font=font, fill=accent["body"])
paragraph_start = False
y += (19 if is_small else 23) + line_gap
# 可选行动两列(最下面,footer 前):一行两个选项
if hint_pairs:
y += paragraph_gap
draw.text((pad, y), "不知道写什么?可参考", font=small_font, fill=accent["muted"])
y += hint_line_height
col_gap = 24
col_w = (width - pad * 2 - col_gap) // 2
right_x = pad + col_w + col_gap
for left, right in hint_pairs:
if left:
draw.text((pad, y), _fit_by_pixel(measure_draw, left, small_font, col_w), font=small_font, fill=accent["body"])
if right:
draw.text((right_x, y), _fit_by_pixel(measure_draw, right, small_font, col_w), font=small_font, fill=accent["body"])
y += hint_line_height
if footer:
draw.text((pad, height - 36), _fit_by_pixel(measure_draw, footer, foot_font, content_width), font=foot_font, fill=accent["muted"])
digest = hashlib.sha1((title + subtitle + "\n".join(t for t, _ in raw_lines) + footer + str(time.time())).encode("utf-8")).hexdigest()[:12]
path = out_dir / f"card_{digest}.png"
image.save(path, "PNG")
return path
def cleanup_card_cache(
out_dir: Path,
*,
max_age_hours: float = 24,
max_files: int = 200,
delete_all: bool = False,
) -> dict[str, int]:
"""清理 QQ 卡片 PNG 缓存。
只处理当前卡片目录下的 ``card_*.png``,不递归,不碰用户上传或其他资源。
``max_age_hours <= 0`` 表示不按时间清理;``max_files <= 0`` 表示不按数量清理。
"""
out_dir = Path(out_dir)
if not out_dir.exists():
return {"scanned": 0, "deleted": 0, "kept": 0, "bytes_deleted": 0}
files = [path for path in out_dir.glob("card_*.png") if path.is_file()]
scanned = len(files)
deleted = 0
bytes_deleted = 0
now = time.time()
cutoff = now - max_age_hours * 3600 if max_age_hours > 0 else None
by_mtime_desc = sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
keep_by_count = set(by_mtime_desc[:max_files]) if max_files > 0 else set(files)
for path in files:
should_delete = delete_all
if not should_delete and cutoff is not None:
try:
should_delete = path.stat().st_mtime < cutoff
except FileNotFoundError:
continue
if not should_delete and max_files > 0:
should_delete = path not in keep_by_count
if not should_delete:
continue
try:
size = path.stat().st_size
path.unlink()
deleted += 1
bytes_deleted += size
except FileNotFoundError:
continue
return {
"scanned": scanned,
"deleted": deleted,
"kept": max(0, scanned - deleted),
"bytes_deleted": bytes_deleted,
}
def _wrap_by_pixel(draw, text: str, font, max_width: int, first_line_max_width: int | None = None) -> list[str]:
"""按像素宽度换行,比固定字数更适合中英文混排。"""
text = str(text or "").strip()
if not text:
return []
max_width = max(1, int(max_width))
first_line_max_width = max_width if first_line_max_width is None else max(1, int(first_line_max_width))
result: list[str] = []
current = ""
for char in text:
trial = current + char
bbox = draw.textbbox((0, 0), trial, font=font)
line_max_width = first_line_max_width if not result else max_width
if bbox[2] - bbox[0] <= line_max_width or not current:
current = trial
continue
result.append(current)
current = char
if current:
result.append(current)
return result
def _fit_by_pixel(draw, text: str, font, max_width: int) -> str:
"""把单行文本压进指定像素宽度,超出时加省略号。"""
text = str(text or "").strip()
max_width = max(1, int(max_width))
if not text:
return ""
if _text_width(draw, text, font) <= max_width:
return text
ellipsis = "…"
ellipsis_width = _text_width(draw, ellipsis, font)
current = ""
for char in text:
trial = current + char
if _text_width(draw, trial, font) + ellipsis_width > max_width:
break
current = trial
return (current.rstrip() + ellipsis) if current else ellipsis
def _text_width(draw, text: str, font) -> int:
bbox = draw.textbbox((0, 0), str(text or ""), font=font)
return bbox[2] - bbox[0]
def _accent_for(seed: str) -> dict[str, tuple[int, int, int]]:
# 保持克制:背景和文字固定,只让细边框有一点点轻微色差。
palettes = [
{
"bg": (246, 244, 239),
"panel": (255, 254, 251),
"outline": (221, 215, 205),
"title": (39, 35, 31),
"body": (58, 53, 48),
"muted": (126, 116, 104),
"line": (232, 226, 216),
},
{
"bg": (244, 247, 250),
"panel": (255, 255, 255),
"outline": (210, 219, 229),
"title": (34, 39, 46),
"body": (53, 59, 67),
"muted": (112, 124, 138),
"line": (225, 231, 238),
},
{
"bg": (244, 248, 244),
"panel": (255, 255, 253),
"outline": (211, 224, 211),
"title": (34, 43, 35),
"body": (53, 64, 54),
"muted": (111, 130, 111),
"line": (225, 235, 225),
},
{
"bg": (249, 246, 241),
"panel": (255, 254, 252),
"outline": (226, 215, 199),
"title": (45, 38, 31),
"body": (65, 56, 48),
"muted": (136, 119, 100),
"line": (235, 227, 216),
},
]
value = int(hashlib.sha1(seed.encode("utf-8")).hexdigest()[:2], 16)
return palettes[value % len(palettes)]
def _paint_background(draw, width: int, height: int, accent: dict[str, tuple[int, int, int]]) -> None:
bg = accent["bg"]
draw.rectangle((0, 0, width, height), fill=bg)