import re import os import aiohttp import ssl import certifi from io import BytesIO from typing import List, Tuple from abc import ABC, abstractmethod from astrbot.core.config import VERSION from . import RenderStrategy from PIL import ImageFont, Image, ImageDraw from astrbot.core.utils.io import save_temp_img from astrbot.core.utils.astrbot_path import get_astrbot_data_path class FontManager: """字体管理类,负责加载和缓存字体""" _font_cache = {} @classmethod def get_font(cls, size: int) -> ImageFont.FreeTypeFont|ImageFont.ImageFont: """获取指定大小的字体,优先从缓存获取""" if size in cls._font_cache: return cls._font_cache[size] # 首先尝试加载自定义字体 try: font_path = os.path.join(get_astrbot_data_path(), "font.ttf") font = ImageFont.truetype(font_path, size) cls._font_cache[size] = font return font except Exception: pass # 跨平台常见字体列表 fonts = [ "msyh.ttc", # Windows "NotoSansCJK-Regular.ttc", # Linux "msyhbd.ttc", # Windows "PingFang.ttc", # macOS "Heiti.ttc", # macOS "Arial.ttf", # 通用 "DejaVuSans.ttf", # Linux ] for font_name in fonts: try: font = ImageFont.truetype(font_name, size) cls._font_cache[size] = font return font except Exception: continue # 如果所有字体都失败,使用默认字体 try: default_font = ImageFont.load_default() # PIL默认字体大小固定,这里不缓存 return default_font except Exception: raise RuntimeError("无法加载任何字体") class TextMeasurer: """测量文本尺寸的工具类""" @staticmethod def get_text_size(text: str, font: ImageFont.FreeTypeFont|ImageFont.ImageFont) -> tuple[int, int]: """获取文本的尺寸""" # 依赖库Pillow>=11.2.1,不再需要考虑<9.0.0 left, top, right, bottom = font.getbbox("Hello world") return int(right - left), int(bottom - top) @staticmethod def split_text_to_fit_width( text: str, font: ImageFont.FreeTypeFont|ImageFont.ImageFont, max_width: int ) -> list[str]: """将文本拆分为多行,确保每行不超过指定宽度""" lines = [] if not text: return lines remaining_text = text while remaining_text: # 如果文本宽度小于最大宽度,直接添加 text_width = TextMeasurer.get_text_size(remaining_text, font)[0] if text_width <= max_width: lines.append(remaining_text) break # 尝试逐字计算能放入当前行的最多字符 for i in range(len(remaining_text), 0, -1): width = TextMeasurer.get_text_size(remaining_text[:i], font)[0] if width <= max_width: lines.append(remaining_text[:i]) remaining_text = remaining_text[i:] break else: # 如果单个字符都放不下,强制放一个字符 lines.append(remaining_text[0]) remaining_text = remaining_text[1:] return lines class MarkdownElement(ABC): """Markdown元素的基类""" def __init__(self, content: str): self.content = content @abstractmethod def calculate_height(self, image_width: int, font_size: int) -> int: """计算元素的高度""" pass @abstractmethod def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: """渲染元素到图像,返回新的y坐标""" pass class TextElement(MarkdownElement): """普通文本元素""" def calculate_height(self, image_width: int, font_size: int) -> int: if not self.content.strip(): return 10 # 空行高度 font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) return len(lines) * (font_size + 8) def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: if not self.content.strip(): return y + 10 # 空行 font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 return y class BoldTextElement(MarkdownElement): """粗体文本元素""" def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) return len(lines) * (font_size + 8) def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: # 尝试使用粗体字体,如果没有则绘制两次模拟粗体效果 try: bold_fonts = [ "msyhbd.ttc", # 微软雅黑粗体 (Windows) "Arial-Bold.ttf", # Arial粗体 "DejaVuSans-Bold.ttf", # Linux粗体 ] bold_font = None for font_name in bold_fonts: try: bold_font = ImageFont.truetype(font_name, font_size) break except Exception: continue if bold_font: lines = TextMeasurer.split_text_to_fit_width( self.content, bold_font, image_width - 20 ) for line in lines: draw.text((x, y), line, font=bold_font, fill=(0, 0, 0)) y += font_size + 8 else: # 如果没有粗体字体,则绘制两次文本轻微偏移以模拟粗体 font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) draw.text((x + 1, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 except Exception: # 兜底方案:使用普通字体 font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 return y class ItalicTextElement(MarkdownElement): """斜体文本元素""" def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) return len(lines) * (font_size + 8) def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: # 尝试使用斜体字体,如果没有则使用倾斜变换模拟斜体效果 try: italic_fonts = [ "msyhi.ttc", # 微软雅黑斜体 (Windows) "Arial-Italic.ttf", # Arial斜体 "DejaVuSans-Oblique.ttf", # Linux斜体 ] italic_font = None for font_name in italic_fonts: try: italic_font = ImageFont.truetype(font_name, font_size) break except Exception: continue if italic_font: lines = TextMeasurer.split_text_to_fit_width( self.content, italic_font, image_width - 20 ) for line in lines: draw.text((x, y), line, font=italic_font, fill=(0, 0, 0)) y += font_size + 8 else: # 如果没有斜体字体,使用变换 font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) for line in lines: # 先创建一个临时图像用于倾斜处理 text_width, text_height = TextMeasurer.get_text_size(line, font) text_img = Image.new( "RGBA", (text_width + 20, text_height + 10), (0, 0, 0, 0) ) text_draw = ImageDraw.Draw(text_img) text_draw.text((0, 0), line, font=font, fill=(0, 0, 0, 255)) # 倾斜变换,使用仿射变换实现斜体效果 # 变换矩阵: [1, 0.2, 0, 0, 1, 0] italic_img = text_img.transform( text_img.size, Image.Transform.AFFINE, (1, 0.2, 0, 0, 1, 0), Image.Resampling.BICUBIC ) # 粘贴到原图像 image.paste(italic_img, (x, y), italic_img) y += font_size + 8 except Exception: # 兜底方案:使用普通字体 font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 return y class UnderlineTextElement(MarkdownElement): """下划线文本元素""" def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) return len(lines) * (font_size + 8) def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) for line in lines: # 绘制文本 draw.text((x, y), line, font=font, fill=(0, 0, 0)) # 绘制下划线 text_width, _ = TextMeasurer.get_text_size(line, font) underline_y = y + font_size + 2 draw.line( (x, underline_y, x + text_width, underline_y), fill=(0, 0, 0), width=1 ) y += font_size + 8 return y class StrikethroughTextElement(MarkdownElement): """删除线文本元素""" def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) return len(lines) * (font_size + 8) def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) for line in lines: # 绘制文本 draw.text((x, y), line, font=font, fill=(0, 0, 0)) # 绘制删除线 text_width, _ = TextMeasurer.get_text_size(line, font) strike_y = y + font_size // 2 draw.line((x, strike_y, x + text_width, strike_y), fill=(0, 0, 0), width=1) y += font_size + 8 return y class HeaderElement(MarkdownElement): """标题元素""" def __init__(self, content: str): # 去除开头的 # 并计算级别 level = 0 for char in content: if char == "#": level += 1 else: break super().__init__(content[level:].strip()) self.level = min(level, 6) # h1-h6 def calculate_height(self, image_width: int, font_size: int) -> int: header_font_size = 42 - (self.level - 1) * 4 font = FontManager.get_font(header_font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 20 ) return len(lines) * header_font_size + 30 # 包含上下间距和分隔线 def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: header_font_size = 42 - (self.level - 1) * 4 font = FontManager.get_font(header_font_size) y += 10 # 上间距 draw.text((x, y), self.content, font=font, fill=(0, 0, 0)) # 添加分隔线 y += header_font_size + 8 draw.line((x, y, image_width - 10, y), fill=(230, 230, 230), width=3) return y + 10 # 返回包含下间距的新y坐标 class QuoteElement(MarkdownElement): """引用元素""" def __init__(self, content: str): # 去除开头的 > super().__init__(content[1:].strip()) def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 30 ) # 左边留出引用线的空间 return len(lines) * (font_size + 6) + 12 # 包含上下间距 def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 30 ) total_height = len(lines) * (font_size + 6) # 绘制引用线 quote_line_x = x + 3 draw.line( (quote_line_x, y + 6, quote_line_x, y + total_height + 6), fill=(180, 180, 180), width=5, ) # 绘制文本 text_x = x + 15 text_y = y + 6 for line in lines: draw.text((text_x, text_y), line, font=font, fill=(180, 180, 180)) text_y += font_size + 6 return y + total_height + 12 class ListItemElement(MarkdownElement): """列表项元素""" def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 30 ) # 左边留出项目符号的空间 return len(lines) * (font_size + 6) + 16 # 包含上下间距 def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: font = FontManager.get_font(font_size) lines = TextMeasurer.split_text_to_fit_width( self.content, font, image_width - 30 ) y += 8 # 上间距 # 绘制项目符号 bullet_x = x + 5 draw.text((bullet_x, y), "•", font=font, fill=(0, 0, 0)) # 绘制文本 text_x = x + 25 text_y = y for line in lines: draw.text((text_x, text_y), line, font=font, fill=(0, 0, 0)) text_y += font_size + 6 return text_y + 8 # 包含下间距 class CodeBlockElement(MarkdownElement): """代码块元素""" def __init__(self, content: list[str]): super().__init__("\n".join(content)) def calculate_height(self, image_width: int, font_size: int) -> int: if not self.content: return 40 # 空代码块的最小高度 font = FontManager.get_font(font_size) lines = self.content.split("\n") wrapped_lines = [] for line in lines: wrapped = TextMeasurer.split_text_to_fit_width(line, font, image_width - 40) wrapped_lines.extend(wrapped) return len(wrapped_lines) * (font_size + 4) + 40 # 包含内边距和上下间距 def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: font = FontManager.get_font(font_size) lines = self.content.split("\n") wrapped_lines = [] for line in lines: wrapped = TextMeasurer.split_text_to_fit_width(line, font, image_width - 40) wrapped_lines.extend(wrapped) content_height = len(wrapped_lines) * (font_size + 4) total_height = content_height + 30 # 包含内边距 # 绘制背景 draw.rounded_rectangle( (x, y + 5, image_width - 10, y + total_height), radius=5, fill=(240, 240, 240), width=1, ) # 绘制代码 text_y = y + 15 for line in wrapped_lines: draw.text((x + 15, text_y), line, font=font, fill=(0, 0, 0)) text_y += font_size + 4 return y + total_height + 10 class InlineCodeElement(MarkdownElement): """行内代码元素""" def calculate_height(self, image_width: int, font_size: int) -> int: return font_size + 16 # 包含内边距和上下间距 def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: font = FontManager.get_font(font_size) # 计算文本大小 text_width, _ = TextMeasurer.get_text_size(self.content, font) text_height = font_size # 绘制背景 padding = 4 draw.rounded_rectangle( (x, y + 4, x + text_width + padding * 2, y + text_height + padding * 2 + 4), radius=5, fill=(230, 230, 230), width=1, ) # 绘制文本 draw.text( (x + padding, y + padding + 4), self.content, font=font, fill=(0, 0, 0) ) return y + text_height + 16 # 返回新的y坐标 class ImageElement(MarkdownElement): """图片元素""" def __init__(self, content: str, image_url: str): super().__init__(content) self.image_url = image_url self.image = None async def load_image(self): """加载图片""" try: ssl_context = ssl.create_default_context(cafile=certifi.where()) connector = aiohttp.TCPConnector(ssl=ssl_context) async with aiohttp.ClientSession( trust_env=True, connector=connector ) as session: async with session.get(self.image_url) as resp: if resp.status == 200: image_data = await resp.read() self.image = Image.open(BytesIO(image_data)) else: print(f"Failed to load image: HTTP {resp.status}") except Exception as e: print(f"Failed to load image: {e}") def calculate_height(self, image_width: int, font_size: int) -> int: if self.image is None: return font_size + 20 # 图片加载失败的默认高度 # 计算调整大小后的图片高度 max_width = image_width * 0.8 if self.image.width > max_width: ratio = max_width / self.image.width height = int(self.image.height * ratio) else: height = self.image.height return height + 30 # 包含上下间距 def render( self, image: Image.Image, draw: ImageDraw.ImageDraw, x: int, y: int, image_width: int, font_size: int, ) -> int: if self.image is None: # 图片加载失败 font = FontManager.get_font(font_size) draw.text((x, y + 10), "[图片加载失败]", font=font, fill=(255, 0, 0)) return y + font_size + 20 # 调整图片大小 max_width = image_width * 0.8 pasted_image = self.image if pasted_image.width > max_width: ratio = max_width / pasted_image.width new_size = (int(max_width), int(pasted_image.height * ratio)) pasted_image = pasted_image.resize(new_size, Image.Resampling.LANCZOS) # 计算居中位置 paste_x = x + (image_width - pasted_image.width) // 2 - 10 # 粘贴图片 if pasted_image.mode == "RGBA": # 处理透明图片 image.paste(pasted_image, (paste_x, y + 15), pasted_image) else: image.paste(pasted_image, (paste_x, y + 15)) return y + pasted_image.height + 30 class MarkdownParser: """Markdown解析器,将文本解析为元素""" @staticmethod async def parse(text: str) -> list[MarkdownElement]: elements = [] lines = text.split("\n") i = 0 while i < len(lines): line = lines[i].rstrip() # 图片检测 image_match = re.search(r"!\s*\[(.*?)\]\s*\((.*?)\)", line) if image_match: image_url = image_match.group(2) element = ImageElement(line, image_url) await element.load_image() elements.append(element) i += 1 continue # 标题 if line.startswith("#"): elements.append(HeaderElement(line)) i += 1 continue # 引用 if line.startswith(">"): elements.append(QuoteElement(line)) i += 1 continue # 列表项 if line.startswith("-") or line.startswith("*"): elements.append(ListItemElement(line[1:].strip())) i += 1 continue # 代码块 if line.startswith("```"): code_lines = [] i += 1 # 跳过开始标记行 while i < len(lines) and not lines[i].startswith("```"): code_lines.append(lines[i]) i += 1 i += 1 # 跳过结束标记行 elements.append(CodeBlockElement(code_lines)) continue # 检查行内样式(粗体、斜体、下划线、删除线、行内代码) if re.search( r"(\*\*.*?\*\*)|(\*.*?\*)|(__.*?__)|(_.*?_)|(~~.*?~~)|(`.*?`)", line ): # 分析行内样式: # - 粗体: **text** 或 __text__ # - 斜体: *text* 或 _text_ # - 删除线: ~~text~~ # - 行内代码: `text` # 定义正则模式和对应的元素类型 patterns = [ (r"\*\*(.*?)\*\*", BoldTextElement), # **粗体** (r"__(.*?)__", BoldTextElement), # __粗体__ ( r"\*((?!\*\*).*?)\*", ItalicTextElement, ), # *斜体* (但不匹配 ** 开头) (r"_((?!__).*?)_", ItalicTextElement), # _斜体_ (但不匹配 __ 开头) (r"~~(.*?)~~", StrikethroughTextElement), # ~~删除线~~ (r"__(.*?)__", UnderlineTextElement), # __下划线__ (r"`(.*?)`", InlineCodeElement), # `行内代码` ] # 创建标记位置列表 markers = [] for pattern, element_class in patterns: for match in re.finditer(pattern, line): markers.append( { "start": match.start(), "end": match.end(), "text": match.group(1), # 提取内容部分 "element_class": element_class, } ) # 按开始位置排序 markers.sort(key=lambda x: x["start"]) # 如果没有找到任何匹配,直接添加为普通文本 if not markers: elements.append(TextElement(line)) i += 1 continue # 处理每个文本片段 current_pos = 0 for marker in markers: # 添加前面的普通文本 if marker["start"] > current_pos: normal_text = line[current_pos : marker["start"]] if normal_text: elements.append(TextElement(normal_text)) # 添加特殊样式的文本 elements.append(marker["element_class"](marker["text"])) current_pos = marker["end"] # 添加最后一段普通文本 if current_pos < len(line): elements.append(TextElement(line[current_pos:])) i += 1 continue # 行内代码 (如果之前没匹配到混合样式) inline_code_matches = re.findall(r"`([^`]+)`", line) if inline_code_matches: parts = re.split(r"`([^`]+)`", line) for j, part in enumerate(parts): if j % 2 == 0: # 普通文本 if part: elements.append(TextElement(part)) else: # 行内代码 elements.append(InlineCodeElement(part)) i += 1 continue # 普通文本 elements.append(TextElement(line)) i += 1 return elements class MarkdownRenderer: """Markdown渲染器,将元素渲染为图像""" def __init__( self, font_size: int = 26, width: int = 800, bg_color: tuple[int, int, int] = (255, 255, 255), ): self.font_size = font_size self.width = width self.bg_color = bg_color async def render(self, markdown_text: str) -> Image.Image: # 解析Markdown文本 elements = await MarkdownParser.parse(markdown_text) # 计算总高度 total_height = 20 # 初始边距 for element in elements: total_height += element.calculate_height(self.width, self.font_size) # 为页脚添加额外空间 footer_height = 40 total_height += 20 + footer_height # 结束边距 + 页脚高度 # 创建图像 image = Image.new("RGB", (self.width, max(100, total_height)), self.bg_color) draw = ImageDraw.Draw(image) # 渲染元素 y = 10 for element in elements: y = element.render(image, draw, 10, y, self.width, self.font_size) # 添加页脚 # 克莱因蓝色,近似RGB为(0, 47, 167) klein_blue = (0, 47, 167) # 灰色 grey_color = (130, 130, 130) # 绘制"Powered by AstrBot"文本 footer_font_size = 20 footer_font = FontManager.get_font(footer_font_size) # 获取"Powered by "和"AstrBot"的宽度以便居中 powered_by_text = "Powered by " astrbot_text = f"AstrBot v{VERSION}" powered_by_width, _ = TextMeasurer.get_text_size(powered_by_text, footer_font) astrbot_width, _ = TextMeasurer.get_text_size(astrbot_text, footer_font) total_width = powered_by_width + astrbot_width x_start = (self.width - total_width) // 2 footer_y = total_height - footer_height # 绘制"Powered by "(灰色) draw.text( (x_start, footer_y), powered_by_text, font=footer_font, fill=grey_color ) # 绘制"AstrBot"(克莱因蓝) draw.text( (x_start + powered_by_width, footer_y), astrbot_text, font=footer_font, fill=klein_blue, ) return image class LocalRenderStrategy(RenderStrategy): """本地渲染策略实现""" async def render_custom_template( self, tmpl_str: str, tmpl_data: dict, return_url: bool = True ) -> str: raise NotImplementedError async def render(self, text: str, return_url: bool = False) -> str: # 创建渲染器 renderer = MarkdownRenderer(font_size=26, width=800) # 渲染Markdown文本 image = await renderer.render(text) # 保存图像并返回路径/URL return save_temp_img(image)