f624971613
* 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>
927 lines
29 KiB
Python
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)
|