"""图片生成模块 - 将文本渲染为统一的通知卡片图片""" 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}"