474 lines
17 KiB
Python
474 lines
17 KiB
Python
"""图片生成模块 - 将文本渲染为统一的通知卡片图片"""
|
||
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 _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)
|
||
badge_text_top = badge_top + max((badge_height - badge_text_height) // 2, 0)
|
||
|
||
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}"
|