From e9eff7420b12ab405433942d9d700df327287347 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 25 Mar 2025 00:56:19 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=9B=B4=E5=8A=A0=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=92=8C=E7=BE=8E=E8=A7=82=E7=9A=84=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20Markdown=20=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 11 +- .../core/pipeline/result_decorate/stage.py | 11 +- astrbot/core/utils/t2i/local_strategy.py | 1100 ++++++++++++----- 3 files changed, 783 insertions(+), 339 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 7db7ff5f..9bcacce9 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -80,6 +80,8 @@ DEFAULT_CONFIG = { "admins_id": ["astrbot"], "t2i": False, "t2i_word_threshold": 150, + "t2i_strategy": "remote", + "t2i_endpoint": "", "http_proxy": "", "dashboard": { "enable": True, @@ -91,7 +93,6 @@ DEFAULT_CONFIG = { "platform": [], "wake_prefix": ["/"], "log_level": "INFO", - "t2i_endpoint": "", "pip_install_arg": "", "plugin_repo_mirror": "", "knowledge_db": {}, @@ -1094,10 +1095,16 @@ CONFIG_METADATA_2 = { "hint": "控制台输出日志的级别。", "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, + "t2i_strategy": { + "description": "文本转图像渲染源", + "type": "string", + "hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。", + "options": ["remote", "local"], + }, "t2i_endpoint": { "description": "文本转图像服务接口", "type": "string", - "hint": "为空时使用 AstrBot API 服务", + "hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务", }, "pip_install_arg": { "description": "pip 安装参数", diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index d80945d5..d7bb9583 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -31,6 +31,8 @@ class ResultDecorateStage(Stage): self.t2i_word_threshold = 50 except BaseException: self.t2i_word_threshold = 150 + self.t2i_strategy = ctx.astrbot_config["t2i_strategy"] + self.t2i_use_network = self.t2i_strategy == "remote" self.forward_threshold = ctx.astrbot_config["platform_settings"][ "forward_threshold" @@ -192,7 +194,9 @@ class ResultDecorateStage(Stage): if plain_str and len(plain_str) > self.t2i_word_threshold: render_start = time.time() try: - url = await html_renderer.render_t2i(plain_str, return_url=True) + url = await html_renderer.render_t2i( + plain_str, return_url=True, use_network=self.t2i_use_network + ) except BaseException: logger.error("文本转图片失败,使用文本发送。") return @@ -201,7 +205,10 @@ class ResultDecorateStage(Stage): "文本转图片耗时超过了 3 秒,如果觉得很慢可以使用 /t2i 关闭文本转图片模式。" ) if url: - result.chain = [Image.fromURL(url)] + if url.startswith("http"): + result.chain = [Image.fromURL(url)] + else: + result.chain = [Image.fromFileSystem(url)] # 触发转发消息 has_forwarded = False diff --git a/astrbot/core/utils/t2i/local_strategy.py b/astrbot/core/utils/t2i/local_strategy.py index aea3f481..514e0dd7 100644 --- a/astrbot/core/utils/t2i/local_strategy.py +++ b/astrbot/core/utils/t2i/local_strategy.py @@ -3,352 +3,782 @@ 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 +class FontManager: + """字体管理类,负责加载和缓存字体""" + + _font_cache = {} + + @classmethod + def get_font(cls, size: int) -> ImageFont.FreeTypeFont: + """获取指定大小的字体,优先从缓存获取""" + if size in cls._font_cache: + return cls._font_cache[size] + + # 首先尝试加载自定义字体 + try: + font = ImageFont.truetype("data/font.ttf", 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) -> Tuple[int, int]: + """获取文本的尺寸""" + try: + # PIL 9.0.0 以上版本 + return font.getbbox(text)[2:] if hasattr(font, 'getbbox') else font.getsize(text) + except Exception: + # 兼容旧版本 + return font.getsize(text) + + @staticmethod + def split_text_to_fit_width(text: str, font: ImageFont.FreeTypeFont, 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.Draw, 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.Draw, 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.Draw, 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.Draw, 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.AFFINE, + (1, 0.2, 0, 0, 1, 0), + Image.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.Draw, 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.Draw, 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.Draw, 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.Draw, 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.Draw, 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.Draw, 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.Draw, 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.Draw, 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.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 - def get_font(self, size: int) -> ImageFont.FreeTypeFont: - # common and default fonts on Windows, macOS and Linux - fonts = [ - "msyh.ttc", - "NotoSansCJK-Regular.ttc", - "msyhbd.ttc", - "PingFang.ttc", - "Heiti.ttc", - ] - for font in fonts: - try: - font = ImageFont.truetype(font, size) - return font - except Exception: - pass - async def render(self, text: str, return_url: bool = False) -> str: - font_size = 26 - image_width = 800 - image_height = 600 - font_color = (0, 0, 0) - bg_color = (255, 255, 255) - - HEADER_MARGIN = 20 - HEADER_FONT_STANDARD_SIZE = 42 - - QUOTE_LEFT_LINE_MARGIN = 10 - QUOTE_FONT_LINE_MARGIN = 6 # 引用文字距离左边线的距离和上下的距离 - QUOTE_LEFT_LINE_HEIGHT = font_size + QUOTE_FONT_LINE_MARGIN * 2 - QUOTE_LEFT_LINE_WIDTH = 5 - QUOTE_LEFT_LINE_COLOR = (180, 180, 180) - QUOTE_FONT_SIZE = font_size - QUOTE_FONT_COLOR = (180, 180, 180) - # QUOTE_BG_COLOR = (255, 255, 255) - - CODE_BLOCK_MARGIN = 10 - CODE_BLOCK_FONT_SIZE = font_size - CODE_BLOCK_FONT_COLOR = (255, 255, 255) - CODE_BLOCK_BG_COLOR = (240, 240, 240) - CODE_BLOCK_CODES_MARGIN_VERTICAL = 5 # 代码块和代码之间的距离 - CODE_BLOCK_CODES_MARGIN_HORIZONTAL = 5 # 代码块和代码之间的距离 - CODE_BLOCK_TEXT_MARGIN = 4 # 代码和代码之间的距离 - - INLINE_CODE_MARGIN = 8 - INLINE_CODE_FONT_SIZE = font_size - INLINE_CODE_FONT_COLOR = font_color - INLINE_CODE_FONT_MARGIN = 4 - INLINE_CODE_BG_COLOR = (230, 230, 230) - INLINE_CODE_BG_HEIGHT = INLINE_CODE_FONT_SIZE + INLINE_CODE_FONT_MARGIN * 2 - - LIST_MARGIN = 8 - LIST_FONT_SIZE = font_size - LIST_FONT_COLOR = font_color - - TEXT_LINE_MARGIN = 8 - - IMAGE_MARGIN = 15 - # 用于匹配图片的正则表达式 - IMAGE_REGEX = r"!\s*\[.*?\]\s*\((.*?)\)" - - # 加载字体 - font = self.get_font(font_size) - - images: Image = {} - - # pre_process, get height of each line - pre_lines = text.split("\n") - height = 0 - pre_in_code = False - i = -1 - _pre_lines = [] - for line in pre_lines: - i += 1 - # 处理图片 - if re.search(IMAGE_REGEX, line): - try: - image_url = re.findall(IMAGE_REGEX, line)[0] - print(image_url) - 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(image_url) as resp: - image_res = Image.open(BytesIO(await resp.read())) - images[i] = image_res - # 最大不得超过image_width的50% - img_height = image_res.size[1] - - if image_res.size[0] > image_width * 0.5: - image_res = image_res.resize( - ( - int(image_width * 0.5), - int( - image_res.size[1] - * image_width - * 0.5 - / image_res.size[0] - ), - ) - ) - img_height = image_res.size[1] - - height += img_height + IMAGE_MARGIN * 2 - - line = re.sub(IMAGE_REGEX, "", line) - except Exception as e: - print(e) - line = re.sub(IMAGE_REGEX, "\n[加载失败的图片]\n", line) - continue - - line.replace("\t", " ") - if font.getsize(line)[0] > image_width: - cp = line - _width = 0 - _word_cnt = 0 - for ii in range(len(line)): - # 检测是否是中文 - _width += font.getsize(line[ii])[0] - _word_cnt += 1 - if _width > image_width: - _pre_lines.append(cp[:_word_cnt]) - cp = cp[_word_cnt:] - _word_cnt = 0 - _width = 0 - _pre_lines.append(cp) - else: - _pre_lines.append(line) - pre_lines = _pre_lines - - i = -1 - for line in pre_lines: - if line == "": - height += TEXT_LINE_MARGIN - continue - i += 1 - line = line.strip() - if pre_in_code and not line.startswith("```"): - height += font_size + CODE_BLOCK_TEXT_MARGIN - # pre_codes.append(line) - continue - if line.startswith("#"): - header_level = line.count("#") - height += ( - HEADER_FONT_STANDARD_SIZE + HEADER_MARGIN * 2 - header_level * 4 - ) - elif line.startswith("-"): - height += font_size + LIST_MARGIN * 2 - elif line.startswith(">"): - height += font_size + QUOTE_LEFT_LINE_MARGIN * 2 - elif line.startswith("```"): - if pre_in_code: - pre_in_code = False - # pre_codes = [] - height += CODE_BLOCK_MARGIN - else: - pre_in_code = True - height += CODE_BLOCK_MARGIN - elif re.search(r"`(.*?)`", line): - height += ( - font_size + INLINE_CODE_FONT_MARGIN * 2 + INLINE_CODE_MARGIN * 2 - ) - else: - height += font_size + TEXT_LINE_MARGIN * 2 - - text = "\n".join(pre_lines) - image_height = height - if image_height < 100: - image_height = 100 - image_width += 20 - - # 创建空白图像 - image = Image.new("RGB", (image_width, image_height), bg_color) - draw = ImageDraw.Draw(image) - - # 设置初始位置 - x, y = 10, 10 - - # 解析Markdown文本 - lines = text.split("\n") - # lines = pre_lines - - in_code_block = False - code_block_start_y = 0 - code_block_codes = [] - - index = -1 - for line in lines: - index += 1 - if in_code_block and not line.startswith("```"): - code_block_codes.append(line) - y += font_size + CODE_BLOCK_TEXT_MARGIN - continue - line = line.strip() - - if line.startswith("#"): - # 处理标题 - header_level = line.count("#") - line = line.strip("#").strip() - font_size_header = HEADER_FONT_STANDARD_SIZE - header_level * 4 - font = self.get_font(font_size_header) - y += HEADER_MARGIN # 上边距 - # 字间距 - draw.text((x, y), line, font=font, fill=font_color) - draw.line( - ( - x, - y + font_size_header + 8, - image_width - 10, - y + font_size_header + 8, - ), - fill=(230, 230, 230), - width=3, - ) - y += font_size_header + HEADER_MARGIN - - elif line.startswith(">"): - # 处理引用 - quote_text = line.strip(">") - y += QUOTE_LEFT_LINE_MARGIN - draw.line( - (x, y, x, y + QUOTE_LEFT_LINE_HEIGHT), - fill=QUOTE_LEFT_LINE_COLOR, - width=QUOTE_LEFT_LINE_WIDTH, - ) - font = self.get_font(QUOTE_FONT_SIZE) - draw.text( - (x + QUOTE_FONT_LINE_MARGIN, y + QUOTE_FONT_LINE_MARGIN), - quote_text, - font=font, - fill=QUOTE_FONT_COLOR, - ) - y += font_size + QUOTE_LEFT_LINE_HEIGHT + QUOTE_LEFT_LINE_MARGIN - - elif line.startswith("-"): - # 处理列表 - list_text = line.strip("-").strip() - font = self.get_font(LIST_FONT_SIZE) - y += LIST_MARGIN - draw.text((x, y), " · " + list_text, font=font, fill=LIST_FONT_COLOR) - y += font_size + LIST_MARGIN - - elif line.startswith("```"): - if not in_code_block: - code_block_start_y = y + CODE_BLOCK_MARGIN - in_code_block = True - else: - # print(code_block_codes) - in_code_block = False - codes = "\n".join(code_block_codes) - code_block_codes = [] - draw.rounded_rectangle( - ( - x, - code_block_start_y, - image_width - 10, - y - + CODE_BLOCK_CODES_MARGIN_VERTICAL - + CODE_BLOCK_TEXT_MARGIN, - ), - radius=5, - fill=CODE_BLOCK_BG_COLOR, - width=2, - ) - font = self.get_font(CODE_BLOCK_FONT_SIZE) - draw.text( - ( - x + CODE_BLOCK_CODES_MARGIN_HORIZONTAL, - code_block_start_y + CODE_BLOCK_CODES_MARGIN_VERTICAL, - ), - codes, - font=font, - fill=font_color, - ) - y += CODE_BLOCK_CODES_MARGIN_VERTICAL + CODE_BLOCK_MARGIN - # y += font_size+10 - elif re.search(r"`(.*?)`", line): - y += INLINE_CODE_MARGIN # 上边距 - # 处理行内代码 - code_regex = r"`(.*?)`" - parts_inline = re.findall(code_regex, line) - parts = re.split(code_regex, line) - for part in parts: - # the judge has a tiny bug. - # when line is like "hi`hi`". all the parts will be in parts_inline. - if part in parts_inline: - font = self.get_font(INLINE_CODE_FONT_SIZE) - code_text = part.strip("`") - code_width = ( - font.getsize(code_text)[0] + INLINE_CODE_FONT_MARGIN * 2 - ) - x += INLINE_CODE_MARGIN - code_box = (x, y, x + code_width, y + INLINE_CODE_BG_HEIGHT) - draw.rounded_rectangle( - code_box, radius=5, fill=INLINE_CODE_BG_COLOR, width=2 - ) # 使用灰色填充矩形框作为引用背景 - draw.text( - (x + INLINE_CODE_FONT_MARGIN, y), - code_text, - font=font, - fill=font_color, - ) - x += code_width + INLINE_CODE_MARGIN - INLINE_CODE_FONT_MARGIN - else: - font = self.get_font(font_size) - draw.text((x, y), part, font=font, fill=font_color) - x += font.getsize(part)[0] - y += font_size + INLINE_CODE_MARGIN - x = 10 - - else: - # 处理普通文本 - if line == "": - y += TEXT_LINE_MARGIN - else: - font = self.get_font(font_size) - - draw.text((x, y), line, font=font, fill=font_color) - y += font_size + TEXT_LINE_MARGIN * 2 - - # 图片特殊处理 - if index in images: - image_res = images[index] - # 最大不得超过image_width的50% - if image_res.size[0] > image_width * 0.5: - image_res = image_res.resize( - ( - int(image_width * 0.5), - int( - image_res.size[1] - * image_width - * 0.5 - / image_res.size[0] - ), - ) - ) - image.paste(image_res, (IMAGE_MARGIN, y)) - y += image_res.size[1] + IMAGE_MARGIN * 2 + # 创建渲染器 + renderer = MarkdownRenderer(font_size=26, width=800) + + # 渲染Markdown文本 + image = await renderer.render(text) + + # 保存图像并返回路径/URL return save_temp_img(image)