main
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3from pathlib import Path
4
5from glom import glom
6from loguru import logger
7from PIL import Image, ImageDraw, ImageFont
8
9from config import DOWNLOAD_DIR, FONTS_DIR
10from networking import download_file
11from quotly.fonts import CJK_FONT, EMOJI_FONT, MATH_FONT, SYMBOL_FONT
12from quotly.utils import get_name_color, get_string_width, get_text_segments, split_lines
13from utils import rand_string
14
15
16async def generate_from_pillow(message_info: dict) -> str:
17 """Generate a quote image from PIL.
18
19 message_info example:
20 {
21 "from": {
22 "uid": 123456,
23 "first_name": "First",
24 "last_name": "Last",
25 "username": "alice",
26 "photo": "local_image_path",
27 },
28 "text": "This is a sample message for testing only",
29 "avatar": True,
30 "media": "local_media_path",
31 "entities": [
32 {"type": "bold", "offset": 0, "length": 2},
33 {"type": "spoiler", "offset": 5, "length": 2},
34 {"type": "text_link", "offset": 7, "length": 2, "url": "https://github.com/"},
35 {"type": "strikethrough", "offset": 12, "length": 3},
36 ]
37 }
38 """
39 avatar_path = message_info["from"]["photo"]
40 first_name = glom(message_info, "from.first_name", default="")
41 last_name = glom(message_info, "from.last_name", default="")
42 sender_name = f"{first_name} {last_name}".strip()
43 text = message_info["text"]
44 if not all((text, sender_name, avatar_path)):
45 return "/not-exist-path"
46 if not Path(avatar_path).is_file():
47 return "/not-exist-path"
48 # 以下code由gemini-2.5-pro协助完成
49 # --- 0. 参数和常量定义 ---
50 AVATAR_SIZE = 70 # 头像大小
51 PADDING = 25
52 SPACING = 15 # 头像和文本框间距
53 CORNER_RADIUS = 35 # 文本框圆角半径
54 MAX_TEXT_WIDTH = 650
55 LINE_SPACING = 10
56 BUBBLE_COLOR = (54, 53, 63, 255)
57 NAME_COLOR = get_name_color(glom(message_info, "from.uid", default=0))
58 TEXT_COLOR = (255, 255, 255, 255)
59 NAME_EMOJI_SCALE = 0.28 # 由于emoji字体过大, 无法直接使用, 需要缩放
60 TEXT_EMOJI_SCALE = 0.25
61 FONT_SIZE = 32
62 TEXT_FONT_PATH = await download_font(CJK_FONT, "NotoSansCJK-Regular.otf")
63 TEXT_BOLD_FONT_PATH = await download_font(CJK_FONT.replace("Regular", "Bold"), "NotoSansCJK-Bold.otf")
64 SYMBOL_FONT_PATH = await download_font(SYMBOL_FONT, "NotoSans-Regular.otf") # Contains additional symbols
65 SYMBOL_BOLD_FONT_PATH = await download_font(SYMBOL_FONT.replace("Regular", "Bold"), "NotoSans-Bold.otf") # Contains additional symbols
66 MATH_FONT_PATH = await download_font(MATH_FONT, "NotoSansMath-Regular.otf")
67 EMOJI_FONT_PATH = await download_font(EMOJI_FONT, "AppleColorEmoji.ttf")
68
69 # --- 1. 加载字体 ---
70 if not all(Path(x).is_file() for x in [TEXT_FONT_PATH, SYMBOL_FONT_PATH, EMOJI_FONT_PATH, MATH_FONT_PATH]):
71 logger.error("❌字体下载失败, 请检查网络连接或稍后再试")
72
73 text_font = ImageFont.truetype(TEXT_FONT_PATH, FONT_SIZE)
74 text_bold_font = ImageFont.truetype(TEXT_BOLD_FONT_PATH, FONT_SIZE)
75 symbol_font = ImageFont.truetype(SYMBOL_FONT_PATH, FONT_SIZE)
76 symbol_bold_font = ImageFont.truetype(SYMBOL_BOLD_FONT_PATH, FONT_SIZE)
77 math_font = ImageFont.truetype(MATH_FONT_PATH, FONT_SIZE)
78 emoji_font = ImageFont.truetype(EMOJI_FONT_PATH, 137) # AppleColorEmoji必须以137 size加载, 否则会报错
79
80 # --- 2. 文本处理和尺寸计算 ---
81 temp_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
82
83 # 获取基准文字高度
84 line_bbox = temp_draw.textbbox((0, 0), "测", font=text_font)
85 line_height = line_bbox[3] - line_bbox[1]
86
87 # 计算sender_name的宽度
88 name_segments = get_text_segments(sender_name)
89 name_width = 0
90 for segment_font, segment_text in name_segments:
91 if segment_font == "text":
92 name_width += temp_draw.textlength(segment_text, font=text_bold_font)
93 elif segment_font == "symbol":
94 name_width += temp_draw.textlength(segment_text, font=symbol_bold_font)
95 elif segment_font == "math":
96 name_width += temp_draw.textlength(segment_text, font=math_font)
97 else: # Emoji segment
98 for char in segment_text:
99 emoji_bbox = emoji_font.getbbox(char)
100 emoji_w, emoji_h = emoji_bbox[2] - emoji_bbox[0], emoji_bbox[3] - emoji_bbox[1]
101 if emoji_h > 0:
102 name_width += emoji_w * NAME_EMOJI_SCALE
103
104 # 正文换行处理
105 lines = split_lines(text, MAX_TEXT_WIDTH, text_font, emoji_font, math_font, symbol_font)
106 first_line_width = get_string_width(lines[0], text_font, emoji_font, math_font, symbol_font)
107 # 气泡和画布尺寸计算
108 text_total_height = (line_height * len(lines)) + (LINE_SPACING * (len(lines) - 1))
109 max_line_width = max(name_width, MAX_TEXT_WIDTH if len(lines) > 1 else first_line_width)
110 bubble_width = max_line_width + PADDING * 2
111 bubble_height = line_height + PADDING * 2 + text_total_height
112 canvas_width = AVATAR_SIZE + SPACING + bubble_width
113 total_height = max(bubble_height, AVATAR_SIZE)
114 bottom_padding = round(canvas_width // 7) # 底部留白, 防止文字被"发送时间"遮挡
115
116 # --- 3. 图像合成 ---
117 canvas = Image.new("RGBA", (int(canvas_width), int(total_height) + bottom_padding), (0, 0, 0, 0))
118 draw = ImageDraw.Draw(canvas)
119
120 # 绘制头像
121 with Image.open(avatar_path) as f:
122 avatar = f.convert("RGBA").resize((AVATAR_SIZE, AVATAR_SIZE), Image.Resampling.LANCZOS)
123 mask = Image.new("L", (AVATAR_SIZE, AVATAR_SIZE), 0)
124 ImageDraw.Draw(mask).ellipse((0, 0, AVATAR_SIZE, AVATAR_SIZE), fill=255)
125 avatar.putalpha(mask)
126 canvas.paste(avatar, (0, 0), avatar)
127 # 绘制气泡
128 bubble_x = AVATAR_SIZE + SPACING
129 bubble_y = 0
130 draw.rounded_rectangle((bubble_x, bubble_y, bubble_x + bubble_width, bubble_y + total_height), radius=CORNER_RADIUS, fill=BUBBLE_COLOR)
131
132 # 绘制Sender
133 name_x = bubble_x + PADDING
134 name_y = bubble_y + PADDING // 2
135 current_x = name_x
136 for segment_font, segment_text in name_segments:
137 if segment_font == "text":
138 draw.text((current_x, name_y), segment_text, font=text_bold_font, fill=NAME_COLOR)
139 current_x += draw.textlength(segment_text, font=text_bold_font)
140 elif segment_font == "math":
141 draw.text((current_x, name_y), segment_text, font=math_font, fill=NAME_COLOR)
142 current_x += draw.textlength(segment_text, font=math_font)
143 elif segment_font == "symbol":
144 draw.text((current_x, name_y), segment_text, font=symbol_bold_font, fill=NAME_COLOR)
145 current_x += draw.textlength(segment_text, font=symbol_bold_font)
146 else: # Emoji in name
147 for char in segment_text:
148 emoji_bbox = emoji_font.getbbox(char)
149 emoji_w, emoji_h = emoji_bbox[2] - emoji_bbox[0], emoji_bbox[3] - emoji_bbox[1]
150 if emoji_h == 0:
151 continue
152 temp_img = Image.new("RGBA", (int(emoji_w), int(emoji_h)), (0, 0, 0, 0))
153 ImageDraw.Draw(temp_img).text((-emoji_bbox[0], -emoji_bbox[1]), char, font=emoji_font, embedded_color=True)
154 new_width = int(emoji_w * NAME_EMOJI_SCALE)
155 new_height = int(emoji_h * NAME_EMOJI_SCALE)
156 resized_emoji = temp_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
157 paste_y = name_y + (line_bbox[1] // 2) - 1 # Align with name baseline
158 canvas.paste(resized_emoji, (int(current_x), int(paste_y)), resized_emoji)
159 current_x += new_width
160
161 # 绘制正文
162 text_y = name_y + line_height + PADDING // 2
163 for line in lines:
164 segments = get_text_segments(line)
165 current_x = bubble_x + PADDING
166 for segment_font, segment_text in segments:
167 if segment_font == "text":
168 draw.text((current_x, text_y), segment_text, font=text_font, fill=TEXT_COLOR)
169 current_x += draw.textlength(segment_text, font=text_font)
170 elif segment_font == "math":
171 draw.text((current_x, text_y), segment_text, font=math_font, fill=TEXT_COLOR)
172 current_x += draw.textlength(segment_text, font=math_font)
173 elif segment_font == "symbol":
174 draw.text((current_x, text_y), segment_text, font=symbol_font, fill=TEXT_COLOR)
175 current_x += draw.textlength(segment_text, font=symbol_font)
176 else: # Emoji in text
177 for char in segment_text:
178 emoji_bbox = emoji_font.getbbox(char)
179 emoji_w, emoji_h = emoji_bbox[2] - emoji_bbox[0], emoji_bbox[3] - emoji_bbox[1]
180 if emoji_h == 0:
181 continue
182 temp_img = Image.new("RGBA", (int(emoji_w), int(emoji_h)), (0, 0, 0, 0))
183 ImageDraw.Draw(temp_img).text((-emoji_bbox[0], -emoji_bbox[1]), char, font=emoji_font, embedded_color=True)
184 new_width = int(emoji_w * TEXT_EMOJI_SCALE)
185 new_height = int(emoji_h * TEXT_EMOJI_SCALE)
186 resized_emoji = temp_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
187 paste_y = text_y + (line_bbox[1] // 2) - 1
188 canvas.paste(resized_emoji, (int(current_x), int(paste_y)), resized_emoji)
189 current_x += new_width
190 text_y += line_height + LINE_SPACING
191
192 # --- 4. 保存图像 ---
193 output_path = Path(DOWNLOAD_DIR) / f"{rand_string(16)}.webp"
194 canvas.save(output_path, "WEBP")
195 return output_path.as_posix()
196
197
198async def download_font(url: str, save_name: str) -> str:
199 """Download font."""
200 save_path = Path(FONTS_DIR).joinpath(save_name)
201 if save_path.exists():
202 return save_path.as_posix()
203 logger.info(f"Downloading font `{save_name}` from {url}")
204 return await download_file(url, save_path)