Files
AstrBot/astrbot/core/utils/t2i/local_strategy.py
T
Dt8333 f624971613 chore: fix bunches of type checking errors (#3213)
* chore(core.utils): 🚨 修正错误Lint

* chore(core.provider): 🚨 修复基类错误Lint

* chore(core.utils): 补全session_get()的重载

* chore(core.provider): 🚨 修正实现错误Lint

* chore(core.platform): 🚨 修正platform基类和webchat的错误Lint

* chore(core.platform): 修正错误实现Lint

* fix(core.provider): 修复循环调用和错误assert

* chore(core.platform): 修复部分实现Lint

* chore(core.provider): 补充Dify.text_chat_stream的参数类型

* chore(core.pipeline): 🚨 修复错误Lint

* fix(core.slack): 补充遗漏导入

* chore(core.utils): 修复错误的session_get声明

* chore(core.platform): 移除Lark adapter import中的wildcard

* chore(core.db): 修复声明和部分逻辑

* chore(core.db): 添加typings,使faiss参数能被正确识别。

* chore(core): 修复声明

* chore(core): 修改声明

* chore: 补充faiss声明

* chore(dashboard): 修改实现,减少报错

* chore(package): 修改部分声明与实现,减少报错

* chore(core): 添加Handler的overload,以去除部分assert同时通过类型检查

* chore(core.pipeline): 修改Pipeline Scheduler的execute,将判断属性改为判断类型,通过静态类型检查

* chore(core.config): 添加类型标注,通过类型检查

* chore(core.message): 为File._download_file添加检查,通过类型检查

* fix: 将断言改为条件判断以实现优雅关闭的容错性

* refactor: 移除 discord 客户端中的 assert,改用 if None 判断并抛出异常

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: DiscordPlatformAdapter 对 self.client.user 为 None 做日志并返回,移除断言

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 增强 Lark 相关空值/异常检查并完善日志输出

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 将断言替换为条件检查并加入日志与错误处理

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* chore: 移除LLM生成的无用注释

* refactor: 使用 File.get_file 替换下载逻辑并移除 assert,提供默认 filename

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: Slack Socket 未初始化抛出运行时异常,图片 URL 判空改为非空判断

* refactor: 将 WeChatPadProAdapter 的断言改为空值判断并添加日志

* refactor: 使用 isinstance 替代断言实现类型判断,便于静态检查

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 去除cast,直接使用字段与字典访问,修正端口解析

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 使用 match-case 重构 ProviderManager 加载并通过类型检查抛出 TypeError

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: group_name_display 时若 group 对象为空则记录错误并返回

* fix: 将 _get_current_persona_id 的 assert 替换成 if guard 并返回 None

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 优化插件目录存在性检查及图片URL非空验证,更新JSON排序配置

* fix: 将 datetime_str 的 assert 替换为显式检查并抛出异常

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 移除 cast,改为运行时检查并在找不到调度器时跳过

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 移除 cast,改用 isinstance 检查 FaissVecDB 并警告

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 删除 typing.cast 导入,并在获取文件绝对路径前校验 file_

* refactor: 移除 typing.cast,简化内容安全检查调用

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 将 PlatformMetadata.id 设为必填并在注册时传入 id,移除 cast

* refactor: 移除 cast,改用 HasInitialize 与 isinstance 进行初始化

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 为 ProviderManager.initialize 增加ID类型判断,避免 None 导致 get 失败

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 为 OTTSProvider 与 AzureNativeProvider 引入 _client 与 client 属性改进上下文管理

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 为 Whisper 自托管源添加模型未初始化校验并直接调用 transcribe

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 移除未使用的 cast 导入并简化 platform_name 赋值

* refactor: 引入 cast 并对 id 使用 cast(str, ...) 提升类型安全

* fix: 将 _id_to_sid 返回改为 str,空值返回空串;对 id 与 message_id 使用 cast

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 重构 Discord 处理逻辑:强制 类型转换、优先斜杠指令并优化提及判断

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 统一对 id 获取执行 cast,并在微信消息解析失败时抛错

* Revert "fix: 去除cast,直接使用字段与字典访问,修正端口解析"

This reverts commit 1cbfdf9d1b.

* fix: 百炼 Rerank 会话关闭时返回空结果;初始化 request.prompt 避免空值拼接

* fix: 统一处理搜索结果链接为字符串,新增 _get_url 助手并适配 Bing/Sogo

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 调整 call_handler 泛型、Discord 通道注解及 FishAudioTTS API 请求类型

* refactor: 使用 col(...) 替代列引用并对结果进行 CursorResult 强转

* chore: ruff format

---------

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>
Co-authored-by: Soulter <905617992@qq.com>
2025-12-09 14:13:47 +08:00

927 lines
29 KiB
Python

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)