Files
DanDingNoneBot/danding_bot/plugins/danding_qqpush/image_render.py

505 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""图片生成模块 - 将文本渲染为统一的通知卡片图片"""
import base64
import io
import logging
from contextlib import ExitStack
from typing import Dict, Iterator, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from pilmoji import Pilmoji
try: # 复用 Pilmoji 的 Emoji 识别正则,让 Emoji 序列作为整体参与测量与换行
from pilmoji.helpers import EMOJI_REGEX
except Exception: # pragma: no cover - 依赖内部结构变化时的兜底
EMOJI_REGEX = None
logger = logging.getLogger(__name__)
class ImageRenderer:
"""通知卡片渲染器:冷灰外衬 + 白色圆角卡片 + 品牌头部 + 场景徽章。"""
DEFAULT_FONT_PATHS = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/seguiemj.ttf",
]
# 版式
OUTER_MARGIN = 24
"""卡片与画布边缘的间距(冷灰外衬宽度)"""
CARD_RADIUS = 18
"""卡片圆角半径"""
MIN_HEIGHT = 200
"""图片最小高度"""
BODY_LINE_SPACING_MIN = 1.6
"""正文字段最小行距倍数,保证长文本的可读性"""
# 配色
OUTER_BG_COLOR: Tuple[int, int, int] = (237, 240, 245)
"""冷灰外衬颜色"""
BRAND_COLOR: Tuple[int, int, int] = (22, 119, 255)
"""品牌头部蓝色"""
BRAND_TEXT_COLOR: Tuple[int, int, int] = (255, 255, 255)
"""品牌标题文字颜色"""
DEFAULT_TEXT_COLOR: Tuple[int, int, int] = (31, 35, 41)
"""正文默认墨色,避免纯黑在小字号下的生硬对比"""
DIVIDER_COLOR: Tuple[int, int, int] = (229, 230, 235)
"""分割线颜色"""
FOOTER_TEXT_COLOR: Tuple[int, int, int] = (134, 144, 156)
"""脚注文字颜色"""
BADGE_BG_COLOR: Tuple[int, int, int] = (255, 255, 255)
"""场景徽章底色"""
BRAND_LABEL = "蛋定助手"
"""品牌头部固定文案"""
FOOTER_TEXT = "本消息由蛋定助手自动生成"
"""底部脚注文案"""
DEFAULT_TITLE = "蛋定助手通知您:"
"""默认标题文案(沿用旧版行为以保证向后兼容);显式传 None 或空字符串则不绘制标题行"""
DEFAULT_SCENE = "common"
"""默认场景标识"""
SCENE_STYLES: Dict[str, Dict[str, object]] = {
"common": {"label": "通用通知", "color": (78, 89, 105)},
"group": {"label": "群聊通知", "color": (22, 119, 255)},
"private": {"label": "私聊通知", "color": (250, 84, 28)},
}
"""场景徽章样式表(未知场景回退为 common)"""
# 中文避头尾:行首禁则与行尾禁则
LINE_START_FORBIDDEN = ",。、;:!?)】》」』”’…·,.;:!?)]}"
LINE_END_FORBIDDEN = "(【《「『“‘([{"
def __init__(
self,
width: int = 800,
font_size: int = 24,
padding: int = 30,
line_spacing: float = 1.4,
bg_color: Tuple[int, int, int] = (252, 252, 252),
text_color: Tuple[int, int, int] = (0, 0, 0),
font_paths: list = None
):
"""
初始化图片渲染器
Args:
width: 图片宽度
font_size: 字体大小
padding: 卡片内边距
line_spacing: 行距倍数
bg_color: 卡片底色 (R, G, B)
text_color: 正文字色 (R, G, B);纯黑会回退为柔和墨色
font_paths: 字体文件路径列表
"""
self.width = width
self.font_size = font_size
self.padding = padding
self.line_spacing = line_spacing
self.bg_color = bg_color
# 浅色卡片上的纯黑过于生硬,统一替换为墨色
self.text_color = self.DEFAULT_TEXT_COLOR if tuple(text_color) == (0, 0, 0) else tuple(text_color)
self.font_paths = font_paths or self.DEFAULT_FONT_PATHS.copy()
self._width_cache: Dict[tuple, float] = {}
self._measuring_image: Optional[Image.Image] = None
self._measuring_draw: Optional[ImageDraw.ImageDraw] = None
# Emoji 由 Pilmoji 从远程源获取,一旦失败就持续降级为普通绘制,避免每条通知都卡在超时上
self._emoji_enabled = True
# 加载字体
self.font = self._load_font()
def _load_font(self) -> ImageFont.FreeTypeFont:
"""加载字体"""
return self._load_font_by_size(self.font_size)
def _load_font_by_size(self, font_size: int) -> ImageFont.FreeTypeFont:
"""
加载指定大小的字体
Args:
font_size: 字体大小
Returns:
字体对象
"""
for font_path in self.font_paths:
try:
font = ImageFont.truetype(font_path, font_size)
return font
except Exception:
continue
# 如果都失败了,使用默认字体
return ImageFont.load_default()
def _get_measuring_draw(self) -> ImageDraw.ImageDraw:
"""复用测量用 Draw 对象,避免重复创建临时 Image。"""
if self._measuring_draw is None:
self._measuring_image = Image.new('RGB', (1, 1), color=(255, 255, 255))
self._measuring_draw = ImageDraw.Draw(self._measuring_image)
return self._measuring_draw
def _calculate_text_dimensions(self, text: str) -> Tuple[int, int]:
"""
计算文本的尺寸
Args:
text: 文本内容
Returns:
(宽度, 高度)
"""
return self._text_size(text, self.font)
def _text_size(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
"""
计算指定字体下文本的尺寸
Args:
text: 文本内容
font: 字体对象
Returns:
(宽度, 高度)
"""
draw = self._get_measuring_draw()
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def _vertical_text_origin(
self,
center_y: int,
text: str,
font: ImageFont.FreeTypeFont,
) -> int:
"""
计算让文本可见墨迹在指定高度内垂直居中所需的绘制原点 y。
PIL 以字体升部基准线定位,textbbox 相对绘制原点的 top 通常不为 0
(例如 19px 微软雅黑的中文 bbox 为 (0, 5, w, 24)),直接按
bbox 高度居中会让文字整体偏下,这里改用真实墨迹上下边界来对齐。
Args:
center_y: 目标垂直中心线(图像绝对坐标)
text: 文本内容
font: 字体对象
Returns:
绘制原点 y 坐标
"""
draw = self._get_measuring_draw()
bbox = draw.textbbox((0, 0), text, font=font)
# bbox top/bottom 是相对绘制原点的偏移,故墨迹中点 = origin_y + (bbox[1] + bbox[3]) / 2
return int(round(center_y - (bbox[1] + bbox[3]) / 2))
def _char_width(self, char: str, font: ImageFont.FreeTypeFont) -> float:
"""
测量单个字符宽度,缺字形时按方块宽度兜底。
Args:
char: 单个字符
font: 字体对象
Returns:
字符宽度(像素)
"""
key = (getattr(font, "path", None), getattr(font, "size", self.font_size), char)
width = self._width_cache.get(key)
if width is None:
try:
width = float(font.getlength(char))
except Exception:
width = 0.0
if width <= 0.0:
# 字体缺少该字形(Emoji 常见)时按 em 方块宽度兜底,避免整行挤在一起
width = float(getattr(font, "size", self.font_size))
self._width_cache[key] = width
return width
def _iter_atoms(self, text: str, font: ImageFont.FreeTypeFont) -> Iterator[Tuple[str, float]]:
"""
把文本拆成最小换行单元:普通字符逐个,Emoji 序列整体不拆分。
Args:
text: 单行文本
Yields:
(单元文本, 单元宽度)
"""
chunks = EMOJI_REGEX.split(text) if EMOJI_REGEX is not None else [text]
for index, chunk in enumerate(chunks):
if not chunk:
continue
if index % 2:
# Pilmoji 以 font.size 的方块绘制 Emoji
yield chunk, float(getattr(font, "size", self.font_size))
else:
for char in chunk:
yield char, self._char_width(char, font)
def _measure_text(self, text: str, font: ImageFont.FreeTypeFont) -> float:
"""
测量整段文本宽度(Emoji 按 Pilmoji 的绘制宽度计算)。
Args:
text: 文本内容
font: 字体对象
Returns:
文本宽度(像素)
"""
return sum(width for _, width in self._iter_atoms(text, font))
def _wrap_paragraph(self, paragraph: str, max_width: int) -> List[str]:
"""
智能文本换行(Emoji 整体处理,并做基础的中文避头尾)
Args:
paragraph: 段落文本(不含换行符)
max_width: 最大宽度
Returns:
换行后的文本列表
"""
lines: List[str] = []
current_line = ""
current_width = 0.0
for atom, atom_width in self._iter_atoms(paragraph, self.font):
if current_line and current_width + atom_width > max_width:
if atom in self.LINE_START_FORBIDDEN and len(current_line) > 1:
# 标点不能出现在行首:把前一字符一起挪到下一行
tail = current_line[-1]
lines.append(current_line[:-1])
current_line = tail + atom
current_width = self._char_width(tail, self.font) + atom_width
continue
# 开引号/开括号不能出现在行尾:把它一起挪到下一行
head = ""
if current_line[-1] in self.LINE_END_FORBIDDEN and len(current_line) > 1:
head = current_line[-1]
current_line = current_line[:-1]
lines.append(current_line)
current_line = head + atom
current_width = (self._char_width(head, self.font) if head else 0.0) + atom_width
continue
current_line += atom
current_width += atom_width
if current_line:
lines.append(current_line)
return lines
def _smart_text_wrap(self, text: str, max_width: int) -> List[str]:
"""
智能文本换行(保留原有方法名)
Args:
text: 文本内容
max_width: 最大宽度
Returns:
换行后的文本列表
"""
return self._wrap_paragraph(text, max_width)
def _wrap_text(self, text: str, max_width: int) -> List[str]:
"""
按显式换行符与宽度折行
Args:
text: 文本内容(已处理换行符)
max_width: 正文最大宽度
Returns:
折行后的文本列表
"""
all_lines: List[str] = []
for paragraph in text.split('\n'):
if not paragraph:
all_lines.append("")
continue
all_lines.extend(self._wrap_paragraph(paragraph, max_width))
return all_lines or [""]
def _paint(
self,
pilmoji: Optional[Pilmoji],
draw: ImageDraw.ImageDraw,
xy: Tuple[int, int],
text: str,
font: ImageFont.FreeTypeFont,
fill: Tuple[int, int, int],
) -> None:
"""绘制单行文本;Emoji 源不可用时退化为 PIL 原生绘制,保证通知仍能发出。"""
if pilmoji is not None and self._emoji_enabled:
try:
pilmoji.text(xy, text, font=font, fill=fill)
return
except Exception as exc:
self._emoji_enabled = False
logger.warning("Emoji 渲染失败,本次降级为普通文本绘制: %s", exc)
draw.text(xy, text, font=font, fill=fill)
def _scene_style(self, scene: str) -> Dict[str, object]:
"""获取场景徽章样式,未知场景回退为默认样式。"""
return self.SCENE_STYLES.get(scene) or self.SCENE_STYLES[self.DEFAULT_SCENE]
def render(self, text: str, title: Optional[str] = DEFAULT_TITLE, scene: str = "common") -> bytes:
"""
将文本渲染为通知卡片图片
Args:
text: 文本内容(已处理换行符)
title: 标题文案,默认沿用旧版固定文案;显式传 None 或空字符串时不绘制标题行
scene: 场景标识,支持 common/group/private,未知值回退为 common
Returns:
图片的字节数据
"""
if not text:
text = "(空消息)"
scene_style = self._scene_style(scene)
# 版式计算
margin = self.OUTER_MARGIN
card_left = margin
card_right = self.width - margin
content_left = card_left + self.padding
content_width = max(card_right - self.padding - content_left, 1)
brand_font = self._load_font_by_size(max(int(self.font_size * 1.05), 12))
badge_font = self._load_font_by_size(max(int(self.font_size * 0.8), 11))
heading_font = self._load_font_by_size(max(int(self.font_size * 1.15), 13))
footer_font = self._load_font_by_size(max(int(self.font_size * 0.75), 11))
header_pad_y = max(int(self.font_size * 0.65), 12)
badge_pad_x = max(int(self.font_size * 0.6), 10)
badge_pad_y = max(int(self.font_size * 0.25), 4)
badge_text = str(scene_style["label"])
brand_height = self._text_size(self.BRAND_LABEL, brand_font)[1]
badge_text_height = self._text_size(badge_text, badge_font)[1]
badge_height = badge_text_height + 2 * badge_pad_y
badge_width = int(self._measure_text(badge_text, badge_font)) + 2 * badge_pad_x
header_height = max(brand_height, badge_height) + 2 * header_pad_y
body_line_spacing = max(self.line_spacing, self.BODY_LINE_SPACING_MIN)
body_line_height = int(self._text_size("测试", self.font)[1] * body_line_spacing)
lines = self._wrap_text(text, content_width)
body_height = len(lines) * body_line_height
heading_height = 0
if title:
heading_height = int(self._text_size("测试", heading_font)[1] * 1.5) + int(self.font_size * 0.5)
footer_line_height = self._text_size(self.FOOTER_TEXT, footer_font)[1]
footer_gap = max(int(self.padding * 0.6), 12)
header_bottom = margin + header_height
content_top = header_bottom + self.padding
body_top = content_top + heading_height
body_bottom = body_top + body_height
divider_y = body_bottom + self.padding
footer_top = divider_y + footer_gap
card_bottom = footer_top + footer_line_height + self.padding
height = max(card_bottom + margin, self.MIN_HEIGHT)
# 绘制卡片与头部
image = Image.new('RGB', (self.width, height), color=self.OUTER_BG_COLOR)
draw = ImageDraw.Draw(image)
draw.rounded_rectangle(
[card_left, margin, card_right, height - margin],
radius=self.CARD_RADIUS,
fill=self.bg_color,
)
draw.rounded_rectangle(
[card_left, margin, card_right, header_bottom],
radius=self.CARD_RADIUS,
fill=self.BRAND_COLOR,
)
# 抹平蓝色横幅底部圆角,使其与卡片左右边缘齐平
draw.rectangle(
[card_left, header_bottom - self.CARD_RADIUS, card_right, header_bottom],
fill=self.BRAND_COLOR,
)
badge_top = margin + max((header_height - badge_height) // 2, 0)
badge_left = card_right - self.padding - badge_width
draw.rounded_rectangle(
[badge_left, badge_top, badge_left + badge_width, badge_top + badge_height],
radius=badge_height // 2,
fill=self.BADGE_BG_COLOR,
)
brand_top = margin + max((header_height - brand_height) // 2, 0)
# 按真实墨迹边界居中,避免 textbbox top 偏移导致徽章文字偏下
badge_text_top = self._vertical_text_origin(
badge_top + badge_height // 2,
badge_text,
badge_font,
)
with ExitStack() as stack:
pilmoji: Optional[Pilmoji] = None
if self._emoji_enabled:
try:
pilmoji = stack.enter_context(Pilmoji(image))
except Exception as exc:
self._emoji_enabled = False
logger.warning("Emoji 渲染器初始化失败,降级为普通文本绘制: %s", exc)
self._paint(pilmoji, draw, (content_left, brand_top), self.BRAND_LABEL, brand_font, self.BRAND_TEXT_COLOR)
self._paint(pilmoji, draw, (badge_left + badge_pad_x, badge_text_top), badge_text, badge_font, tuple(scene_style["color"]))
if title:
self._paint(pilmoji, draw, (content_left, content_top), title, heading_font, self.text_color)
y = body_top
for line in lines:
if line:
self._paint(pilmoji, draw, (content_left, y), line, self.font, self.text_color)
y += body_line_height
draw.line(
[(content_left, divider_y), (card_right - self.padding, divider_y)],
fill=self.DIVIDER_COLOR,
width=1,
)
self._paint(pilmoji, draw, (content_left, footer_top), self.FOOTER_TEXT, footer_font, self.FOOTER_TEXT_COLOR)
# 转换为字节流
img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0)
return img_byte_arr.getvalue()
def render_to_base64(self, text: str, title: Optional[str] = DEFAULT_TITLE, scene: str = "common") -> str:
"""
将文本渲染为图片并返回 base64 编码
Args:
text: 文本内容
title: 标题文案,默认沿用旧版固定文案;显式传 None 或空字符串时不绘制标题行
scene: 场景标识,支持 common/group/private,未知值回退为 common
Returns:
base64 编码的图片数据
"""
img_bytes = self.render(text, title=title, scene=scene)
base64_str = base64.b64encode(img_bytes).decode('utf-8')
return f"base64://{base64_str}"