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)