diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 723953f8f..eb307fd74 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -52,33 +52,51 @@ def port_checker(port: int, host: str = "localhost"): return False -def save_temp_img(img: Union[Image.Image, str]) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - # 获得文件创建时间,清除超过 12 小时的 +def save_temp_img(img: Union[Image.Image, bytes], save_name: str | None = None) -> str: + """ + 保存临时图片: + - 自动清理超过 12 小时的临时文件 + - 如果提供了 save_name(含扩展名),直接用作文件名;否则按规则自动生成 + - 根据图片模式自动选择保存格式(RGBA -> PNG,其余 -> JPG) + """ + temp_dir = Path(get_astrbot_data_path()) / "temp" + temp_dir.mkdir(parents=True, exist_ok=True) + + # 清理超过 12 小时的旧文件 + now = time.time() try: - for f in os.listdir(temp_dir): - path = os.path.join(temp_dir, f) - if os.path.isfile(path): - ctime = os.path.getctime(path) - if time.time() - ctime > 3600 * 12: - os.remove(path) + for f in temp_dir.iterdir(): + if f.is_file() and now - f.stat().st_ctime > 3600 * 12: + f.unlink(missing_ok=True) except Exception as e: print(f"清除临时文件失败: {e}") - # 获得时间戳 - timestamp = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" - p = os.path.join(temp_dir, f"{timestamp}.jpg") + # 决定文件名 + if save_name: # 外部指定了名字 + file_name = save_name + path = temp_dir / file_name + else: # 自动生成 + timestamp = f"{int(now)}_{uuid.uuid4().hex[:8]}" + if isinstance(img, Image.Image) and img.mode in ("RGBA", "LA"): + file_name = f"{timestamp}.png" + else: + file_name = f"{timestamp}.jpg" + path = temp_dir / file_name + # 保存文件 if isinstance(img, Image.Image): - img.save(p) - else: - with open(p, "wb") as f: - f.write(img) - return p + if path.suffix.lower() == ".png" or img.mode in ("RGBA", "LA"): + img.save(path, format="PNG") + else: + img.convert("RGB").save(path, format="JPEG", quality=95) + else: # bytes + path.write_bytes(img) + + return str(path) async def download_image_by_url( - url: str, post: bool = False, post_data: dict = None, path=None + url: str, post: bool = False, post_data: dict = None, path=None, save_name=None ) -> str: """ 下载图片, 返回 path @@ -94,7 +112,7 @@ async def download_image_by_url( if post: async with session.post(url, json=post_data) as resp: if not path: - return save_temp_img(await resp.read()) + return save_temp_img(await resp.read(), save_name) else: with open(path, "wb") as f: f.write(await resp.read()) @@ -102,7 +120,7 @@ async def download_image_by_url( else: async with session.get(url) as resp: if not path: - return save_temp_img(await resp.read()) + return save_temp_img(await resp.read(), save_name) else: with open(path, "wb") as f: f.write(await resp.read()) @@ -114,14 +132,13 @@ async def download_image_by_url( async with aiohttp.ClientSession() as session: if post: async with session.get(url, ssl=ssl_context) as resp: - return save_temp_img(await resp.read()) + return save_temp_img(await resp.read(), save_name) else: async with session.get(url, ssl=ssl_context) as resp: - return save_temp_img(await resp.read()) + return save_temp_img(await resp.read(), save_name) except Exception as e: raise e - async def download_file(url: str, path: str, show_progress: bool = False): """ 从指定 url 下载文件到指定路径 path diff --git a/astrbot/core/utils/t2i/local_strategy.py b/astrbot/core/utils/t2i/local_strategy.py index 19eab2efe..ed543b04c 100644 --- a/astrbot/core/utils/t2i/local_strategy.py +++ b/astrbot/core/utils/t2i/local_strategy.py @@ -1,932 +1,28 @@ -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: - """获取指定大小的字体,优先从缓存获取""" - 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) -> 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 - +from astrbot.core.utils.t2i import RenderStrategy +from astrbot.core.utils.t2i.pillowmd.mdrenderer import PillowMdRenderer +from astrbot.core.utils.t2i.style_manager import StyleManeger class LocalRenderStrategy(RenderStrategy): """本地渲染策略实现""" + def __init__(self): + self.style_maneger = StyleManeger() + self.renderer = PillowMdRenderer() + async def render_custom_template( - self, tmpl_str: str, tmpl_data: dict, return_url: bool = True + self, tmpl_str: str, tmpl_data: dict, options: dict | None = None ) -> str: - raise NotImplementedError - - async def render(self, text: str, return_url: bool = False) -> str: - # 创建渲染器 - renderer = MarkdownRenderer(font_size=26, width=800) - + style = self.style_maneger.get_style_from_dict(tmpl_data) # 渲染Markdown文本 - image = await renderer.render(text) - - # 保存图像并返回路径/URL + image = await self.renderer.md_to_image(text=tmpl_str, style=style) + # 保存图像并返回路径 return save_temp_img(image) + + async def render(self, text: str, style_name: str|None=None) -> str: + style = self.style_maneger.get_style_from_name(style_name) + # 渲染Markdown文本 + image = await self.renderer.md_to_image(text=text, style=style) + # 保存图像并返回路径 + return save_temp_img(image) \ No newline at end of file diff --git a/astrbot/core/utils/t2i/pillowmd/__init__.py b/astrbot/core/utils/t2i/pillowmd/__init__.py new file mode 100644 index 000000000..8b03a7610 --- /dev/null +++ b/astrbot/core/utils/t2i/pillowmd/__init__.py @@ -0,0 +1,13 @@ +""" +Markdown额外语法说明 + + 以强制设定文本颜色 + 以取消强制设定文本颜色 + +!sgexter[对象名,参数1,参数2]绘制自定义对象(以下4个为预设对象): +- probar 进度条 [str,float,int,str] [标签,百分比,长度,显示] +- balbar 平衡条 [str,float,int] [标签,平衡度,长度] +- chabar 条形统计图[list[[str,int],...],int,int] [对象组[[对象名,对象所占比],...],x宽度,y宽度] +- card 卡片 [str,str,int,int,str] [标题,内容,x宽度,y宽度,图片绝对文件路径] + +""" diff --git a/astrbot/core/utils/t2i/pillowmd/decorates.py b/astrbot/core/utils/t2i/pillowmd/decorates.py new file mode 100644 index 000000000..9f0dc59eb --- /dev/null +++ b/astrbot/core/utils/t2i/pillowmd/decorates.py @@ -0,0 +1,240 @@ +from PIL import Image +from pathlib import Path + + +class MDDecorates: + """ + 管理 Markdown 渲染所需的背景与装饰图层。 + 支持: + - 背景模式(0 单图填充 / 1 九宫格) + - 顶部/底部装饰图(九宫格时可选“包含在中心区域”) + """ + + def __init__( + self, + backGroundMode: int, + backGroundData: dict, + topDecorates: dict, + bottomDecorates: dict, + backGroundsPath: Path, + ) -> None: + self.backGroundMode = backGroundMode + self.backGroundData = backGroundData + self.topDecorates = topDecorates + self.bottomDecorates = bottomDecorates + self.backGroundsPath = backGroundsPath + self.imageCache = {} + + def _get_image(self, name: str) -> Image.Image: + if name not in self.imageCache: + p: Path = self.backGroundsPath / name + if not p.is_file(): + raise FileNotFoundError(f"背景图片不存在: {p.resolve()}") + self.imageCache[name] = Image.open(p) + return self.imageCache[name] + + def _calc_nine_patch_bounds(self, x: int, y: int, bdata: dict): + """九宫格模式下计算四角宽高及调整画布大小""" + + corner1 = self._get_image(bdata["left-up"]) + corner2 = self._get_image(bdata["right-up"]) + corner3 = self._get_image(bdata["right-down"]) + corner4 = self._get_image(bdata["left-down"]) + + cxs1 = max(corner1.width, corner4.width) + cxs2 = max(corner2.width, corner3.width) + cys1 = max(corner1.height, corner2.height) + cys2 = max(corner3.height, corner4.height) + + x = max(cxs1 + cxs2 + 1, x) + y = max(cys1 + cys2 + 1, y) + + return (corner1, corner2, corner3, corner4), (cxs1, cys1, cxs2, cys2, x, y) + + def _fill_image(self, bg: Image.Image, img: Image.Image, mode: int) -> None: + """按模式填充背景""" + w, h = bg.size + iw, ih = img.size + + def tile( + img: Image.Image, dx: int, dy: int, offset_x: int = 0, offset_y: int = 0 + ): + for y0 in range(offset_y, h, dy): + for x0 in range(offset_x, w, dx): + bg.paste(img, (x0, y0)) + + if mode == 0: # 单图拉伸 + bg.paste(img.resize((w, h))) + + elif mode == 1: # 九宫格(注意:一般用于 Android NinePatch,这里保留原逻辑) + iw3, ih3 = iw // 3, ih // 3 + parts = [ + img.crop((i * iw3, j * ih3, (i + 1) * iw3, (j + 1) * ih3)) + for j in range(3) + for i in range(3) + ] + bg.paste(parts[4].resize((w - 2 * iw3, h - 2 * ih3)), (iw3, ih3)) + for i in range(3): + bg.paste(parts[i].resize((iw3, h - 2 * ih3)), (i * iw3, ih3)) + bg.paste(parts[6 + i].resize((iw3, h - 2 * ih3)), (i * iw3, h - ih3)) + for j in range(3): + bg.paste(parts[j * 3].resize((w - 2 * iw3, ih3)), (iw3, j * ih3)) + bg.paste( + parts[j * 3 + 2].resize((w - 2 * iw3, ih3)), (w - iw3, j * ih3) + ) + for j in range(3): + for i in range(3): + if (i, j) == (1, 1): + continue + bg.paste(parts[j * 3 + i], (i * iw3, j * ih3)) + + elif mode in (3, 4, 5, 6): # 平铺模式 + if mode == 3: # 横向平铺 + img = img.resize((iw, h)) + tile(img, iw, h) + elif mode == 4: # 纵向平铺 + img = img.resize((w, ih)) + tile(img, w, ih) + elif mode == 5: # 横纵平铺 + tile(img, iw, ih) + elif mode == 6: # 居中平铺 + offset_x, offset_y = (w - iw) // 2 % iw, (h - ih) // 2 % ih + tile(img, iw, ih, offset_x, offset_y) + + def _image_resize( + self, x: int, y: int, img: Image.Image, data: dict + ) -> Image.Image: + match data["mode"]: + case 0: + ... + case 1: + rawSize = img.size + xs1 = int(x * data["xlimit"]) + xs2 = int((y * data["ylimit"]) / rawSize[0] * rawSize[1]) + if xs1 > xs2: + size = (xs2, int(y * data["ylimit"])) + else: + size = (xs1, int(xs1 / rawSize[0] * rawSize[1])) + if "min" in data and size[0] < img.size[0] * data["min"]: + size = (int(size[0] * data["min"]), int(size[1] * data["min"])) + if "max" in data and size[0] > img.size[0] * data["max"]: + size = (int(size[0] * data["max"]), int(size[1] * data["max"])) + img = img.resize(size) + return img + + + def _draw_decorates( + self, + oimg: Image.Image, + decoratesDict: dict, + x: int, + y: int, + bmode: int, + cxs1: int, + cys1: int, + cxs2: int, + cys2: int, + ) -> None: + def pos_func(pos: str, kx: int, ky: int, img: Image.Image) -> tuple[int, int]: + w, h = img.size + return { + "left-up": (0, 0), + "left": (0, int(ky / 2 - h / 2)), + "left-down": (0, ky - h), + "up": (int(kx / 2 - w / 2), 0), + "down": (int(kx / 2 - w / 2), ky - h), + "right-up": (kx - w, 0), + "right": (kx - w, int(ky / 2 - h / 2)), + "right-down": (kx - w, ky - h), + "middle": (int(kx / 2 - w / 2), int(ky / 2 - h / 2)), + }[pos] + + for pos, lst in decoratesDict.items(): + for decorates in lst: + kx, ky = x, y + icMode = False + if bmode == 1 and decorates.get("include"): + kx, ky = max(x - cxs1 - cxs2, 1), max(y - cys1 - cys2, 1) + icMode = True + img = self._image_resize( + kx, ky, self._get_image(decorates["img"]), decorates + ) + k = Image.new("RGBA", (kx, ky)) + k.paste(img, pos_func(pos, kx, ky, img)) + oimg.alpha_composite(k, (cxs1, cys1) if icMode else (0, 0)) + + def Draw(self, x: int, y: int) -> Image.Image: + rx, ry = x, y + oimg = Image.new("RGBA", (x, y)) + bdata = self.backGroundData + cxs1 = cys1 = cxs2 = cys2 = 0 + + if self.backGroundMode == 0: + self._fill_image(oimg, self._get_image(bdata["img"]), bdata["mode"]) + elif self.backGroundMode == 1: + (corner1, corner2, corner3, corner4), (cxs1, cys1, cxs2, cys2, x, y) = ( + self._calc_nine_patch_bounds(x, y, bdata) + ) + oimg = Image.new("RGBA", (x, y)) + oimg.paste(corner1, (0, 0)) + oimg.paste(corner2, (x - cxs2, 0)) + oimg.paste(corner3, (x - cxs2, y - cys2)) + oimg.paste(corner4, (0, y - cys2)) + + img = self._get_image(bdata["up"]) + temp = Image.new("RGBA", (x - cxs1 - cxs2, cys1)) + self._fill_image(temp, img, {0: 0, 1: 3, 2: 5}[bdata["ud-mode"]]) + oimg.paste(temp, (cxs1, 0)) + + img = self._get_image(bdata["down"]) + temp = Image.new("RGBA", (x - cxs1 - cxs2, cys2)) + self._fill_image(temp, img, {0: 0, 1: 3, 2: 5}[bdata["ud-mode"]]) + oimg.paste(temp, (cxs1, y - cys2)) + + img = self._get_image(bdata["left"]) + temp = Image.new("RGBA", (cxs1, y - cys1 - cys2)) + self._fill_image(temp, img, {0: 0, 1: 4, 2: 6}[bdata["lr-mode"]]) + oimg.paste(temp, (0, cys1)) + + img = self._get_image(bdata["right"]) + temp = Image.new("RGBA", (cxs2, y - cys1 - cys2)) + self._fill_image(temp, img, {0: 0, 1: 4, 2: 6}[bdata["lr-mode"]]) + oimg.paste(temp, (x - cxs2, cys1)) + + img = self._get_image(bdata["middle"]) + temp = Image.new("RGBA", (x - cxs1 - cxs2, y - cys1 - cys2)) + self._fill_image(temp, img, bdata["middle-mode"]) + oimg.paste(temp, (cxs1, cys1)) + + self._draw_decorates( + oimg, + self.bottomDecorates, + x, + y, + self.backGroundMode, + cxs1, + cys1, + cxs2, + cys2, + ) + if rx != x: + oimg = oimg.resize((rx, ry)) + return oimg + + def DrawTop(self, x: int, y: int) -> Image.Image: + rx, ry = x, y + oimg = Image.new("RGBA", (x, y)) + cxs1 = cys1 = cxs2 = cys2 = 0 + + if self.backGroundMode == 1: + _, (cxs1, cys1, cxs2, cys2, x, y) = self._calc_nine_patch_bounds( + x, y, self.backGroundData + ) + oimg = Image.new("RGBA", (x, y)) + + self._draw_decorates( + oimg, self.topDecorates, x, y, self.backGroundMode, cxs1, cys1, cxs2, cys2 + ) + if rx != x: + oimg = oimg.resize((rx, ry)) + return oimg diff --git a/astrbot/core/utils/t2i/pillowmd/drawer.py b/astrbot/core/utils/t2i/pillowmd/drawer.py new file mode 100644 index 000000000..aa85fe665 --- /dev/null +++ b/astrbot/core/utils/t2i/pillowmd/drawer.py @@ -0,0 +1,421 @@ +from PIL import Image, ImageDraw +from typing import Optional, Callable, Any, ParamSpec +from .mixfont import MixFont +import random +import inspect + + +class ImageDrawPro(ImageDraw.ImageDraw): + def __init__( + self, + im, + lock_color=None, + blod_mode=None, + delete_line_mode=None, + under_line_mode=None, + mode=None, + ): + super().__init__(im, mode) + self.text_lock_color = lock_color + self.text_blod_mode = blod_mode + self.delete_line_mode = delete_line_mode + self.under_line_mode = under_line_mode + + def text( + self, + xy, + text, + fill=None, + font: Optional[MixFont] = None, + use_lock_color=True, + use_blod_mode=True, + use_delete_line_mode=True, + use_under_line_mode=True, + *args, + **kwargs, + ): + if font is None: + raise SyntaxError("font为必选项") + + if self.text_lock_color and use_lock_color: + fill = self.text_lock_color + + super().text((xy[0], xy[1]), text, fill, font.ft_font, *args, **kwargs) + if self.text_blod_mode and use_blod_mode: + for a, b in [(-1, 0), (1, 0)]: + super().text( + (xy[0] + a, xy[1] + b), text, fill, font.ft_font, *args, **kwargs + ) + + if self.delete_line_mode or self.under_line_mode: + xs, ys = font.GetSize(text) + + if self.delete_line_mode and use_delete_line_mode: + super().line( + ( + xy[0], + xy[1] + int(font.size / 2), + xy[0] + xs, + xy[1] + int(font.size / 2), + ), + fill, + int(font.size / 10) + 1, + ) + + if self.under_line_mode and use_under_line_mode: + super().line( + (xy[0], xy[1] + font.size + 2, xy[0] + xs, xy[1] + font.size + 2), + fill, + int(font.size / 10) + 1, + ) + + +def DefaultMdBackGroundDraw(xs: int, ys: int) -> Image.Image: + image = Image.new("RGBA", (xs, ys), color=(0, 0, 0)) + + drawUnder = ImageDrawPro(image) + for i in range(11): + drawUnder.rectangle( + (0, i * int(ys / 10), xs, (i + 1) * int(ys / 10)), + (52 - 3 * i, 73 - 4 * i, 94 - 2 * i), + ) + + imgUnder2 = Image.new("RGBA", (xs, ys), color=(0, 0, 0, 0)) + drawUnder2 = ImageDrawPro(imgUnder2) + for i in range(int(xs * ys / 20000) + 1): + temp = random.randint(1, 5) + temp1 = random.randint(20, 40) + temp2 = random.randint(10, 80) + temp3 = random.randint(0, xs - temp * 4) + temp4 = random.randint(-50, ys) + for x in range(3): + for y in range(temp1): + if random.randint(1, 2) == 2: + continue + drawUnder2.rectangle( + ( + temp3 + (temp + 2) * x, + temp4 + (temp + 2) * y, + temp3 + (temp + 2) * x + temp, + temp4 + (temp + 2) * y + temp, + ), + (0, 255, 180, temp2), + ) + + image.alpha_composite(imgUnder2) + + return image + + + +class MdExterImageDrawer: + def __init__(self, drawer: Callable[..., Image.Image]): + self.drawer = drawer + + def __call__( + self, *args: Any, nowf: MixFont, style=None, lockColor, **kwds: Any + ) -> Image.Image: + kwds["nowf"] = nowf + kwds["style"] = style + kwds["lockColor"] = lockColor + useVars = inspect.getfullargspec(self.drawer).args + return self.drawer(*args, **{key: kwds[key] for key in kwds if key in useVars}) + + +extendFuncs: dict[str, MdExterImageDrawer] = {} + +P = ParamSpec("P") + + +def NewMdExterImageDrawer( + name: str, +) -> Callable[[Callable[P, Image.Image]], Callable[P, Image.Image]]: + def catch(func: Callable[P, Image.Image]) -> Callable[P, Image.Image]: + extendFuncs[name] = MdExterImageDrawer(func) + return func + + return catch + + +@NewMdExterImageDrawer("probar") +def MakeProbar( + label: str, + pro: float, + size: int, + show: str, + nowf: MixFont, + style = None, +) -> Image.Image: + tempFs = nowf.GetSize(label) + temp = int(nowf.size / 6) + 1 + halfTemp = int(temp / 2) + exterImage = Image.new( + "RGBA", + (tempFs[0] + temp * 3 + size, int(nowf.size + temp * 2)), + color=(0, 0, 0, 0), + ) + drawEm = ImageDraw.Draw(exterImage) + for i in range(11): + drawEm.rectangle( + ( + 0, + i * int((exterImage.size[1]) / 10), + exterImage.size[0], + (i + 1) * int((exterImage.size[1]) / 10), + ), + (40 + 80 - 8 * i, 40 + 80 - 8 * i, 40 + 80 - 8 * i), + ) + drawEm.text((temp - 1, halfTemp), label, "#00CCCC", nowf.ft_font) + drawEm.text((temp + 1, halfTemp), label, "#CCFFFF", nowf.ft_font) + drawEm.text((temp, halfTemp), label, "#33FFFF", nowf.ft_font) + drawEm.rectangle( + (temp * 2 + tempFs[0], temp, temp * 2 + tempFs[0] + size, temp + nowf.size), + (0, 0, 0), + ) + for i in range(20): + drawEm.rectangle( + ( + temp * 2 + tempFs[0] + int(size * pro / 20 * i), + temp, + temp * 2 + tempFs[0] + int(size * pro / 20 * (i + 1)), + temp + nowf.size, + ), + ( + int(78 + 78 * ((i / 20) ** 3)), + int(177 + 177 * ((i / 20) ** 3)), + int(177 + 177 * ((i / 20) ** 3)), + ), + ) + drawEm.text((temp * 3 + tempFs[0], halfTemp), show, (0, 102, 102), nowf.ft_font) + return exterImage + + +@NewMdExterImageDrawer("balbar") +def MakeBalbar( + label: str, bal: float, size: int, nowf: MixFont, style = None +) -> Image.Image: + tempFs = nowf.GetSize(label) + temp = int(nowf.size / 6) + 1 + halfTemp = int(temp / 2) + exterImage = Image.new( + "RGBA", + (tempFs[0] + temp * 3 + size, int(nowf.size + temp * 2)), + color=(0, 0, 0, 0), + ) + drawEm = ImageDraw.Draw(exterImage) + for i in range(11): + drawEm.rectangle( + ( + 0, + i * int((exterImage.size[1]) / 10), + exterImage.size[0], + (i + 1) * int((exterImage.size[1]) / 10), + ), + (40 + 80 - 8 * i, 40 + 80 - 8 * i, 40 + 80 - 8 * i), + ) + drawEm.text((temp - 1, halfTemp), label, "#00CCCC", nowf.ft_font) + drawEm.text((temp + 1, halfTemp), label, "#CCFFFF", nowf.ft_font) + drawEm.text((temp, halfTemp), label, "#33FFFF", nowf.ft_font) + drawEm.rectangle( + (temp * 2 + tempFs[0], temp, temp * 2 + tempFs[0] + size, temp + nowf.size), + (0, 0, 0), + ) + for i in range(20): + drawEm.rectangle( + ( + temp * 2 + tempFs[0] + int(size * bal / 20 * i), + temp, + temp * 2 + tempFs[0] + int(size * bal / 20 * (i + 1)), + temp + nowf.size, + ), + ( + int(78 + 78 * ((i / 20) ** 3)), + int(177 + 177 * ((i / 20) ** 3)), + int(177 + 177 * ((i / 20) ** 3)), + ), + ) + drawEm.rectangle( + ( + temp * 2 + tempFs[0] + size - int(size * (1 - bal) / 20 * (i + 1)), + temp, + temp * 2 + tempFs[0] + size - int(size * (1 - bal) / 20 * i), + temp + nowf.size, + ), + ( + int(177 + 177 * ((i / 20) ** 3)), + int(21 + 21 * ((i / 20) ** 3)), + int(21 + 21 * ((i / 20) ** 3)), + ), + ) + drawEm.line( + ( + temp * 2 + tempFs[0] + int(size * bal), + temp - halfTemp, + temp * 2 + tempFs[0] + int(size * bal), + temp + nowf.size + halfTemp, + ), + (255, 255, 255), + 5, + ) + if bal == 0.5: + drawEm.text( + (temp * 2 + tempFs[0] + int(size * bal) + 3, halfTemp), + "+0%", + (102, 0, 0), + nowf.ft_font, + ) + elif bal > 0.5: + if bal == 1: + text = "+∞%" + else: + text = f"+{round(bal / (1 - bal) * 100 - 100, 2)}%" + drawEm.text( + ( + temp * 2 + tempFs[0] + int(size * bal) - nowf.GetSize(text)[0] - 3, + halfTemp, + ), + text, + (0, 102, 102), + nowf.ft_font, + ) + elif bal < 0.5: + if bal == 0: + text = "-∞%" + else: + text = f"-{round((1 - bal) / bal * 100 - 100, 2)}%" + drawEm.text( + (temp * 2 + tempFs[0] + int(size * bal) + 3, halfTemp), + text, + (102, 0, 0), + nowf.ft_font, + ) + + return exterImage + + +@NewMdExterImageDrawer("chabar") +def MakeChabar( + objs: list[tuple[str, int]], + xSize: int, + ySize: int, + nowf: MixFont, + style = None, +) -> Image.Image: + if not style: + from .style import MdStyle + style = MdStyle() + nums = [nowf.GetSize(str(i[1])) for i in objs] + strs = [nowf.GetSize(i[0]) for i in objs] + space = int(xSize / (len(objs) * 2 + 1)) + halfSpace = int(space / 2) + + exterImage = Image.new( + "RGBA", + ( + int( + max([i[0] for i in nums]) + + xSize + + max(strs[-1][0] / 2 - space * 1.5, 0) + ) + + 5, + int(ySize + nums[0][1] / 2 + max([i[1] for i in strs])) + 5, + ), + color=(0, 0, 0, 0), + ) + drawEm = ImageDraw.Draw(exterImage) + + lineY = int(ySize + nums[0][1] / 2) - 5 + lineX = int(max([i[0] for i in nums]) + 5) + + maxM = max([i[1] for i in objs]) + + for i in range(len(objs)): + X = space * (1 + i * 2) + Y = int(ySize * 0.8 * objs[i][1] / maxM) + color = style.textGradientEndColor + drawEm.line( + (lineX, lineY - Y, lineX + X + space, lineY - Y), + (int(color[0] * 0.6), int(color[1] * 0.6), int(color[2] * 0.6)), + 1, + ) + drawEm.text( + (lineX - nums[i][0] - 5, lineY - Y - int(nums[i][1] / 2)), + str(objs[i][1]), + style.textColor, + nowf.ft_font, + ) + drawEm.text( + (int(lineX + X + space / 2 - strs[i][0] / 2), lineY + 5), + objs[i][0], + style.textColor, + nowf.ft_font, + ) + drawEm.rectangle( + (lineX + X, lineY - Y, lineX + X + space, lineY), style.textGradientEndColor + ) + drawEm.text( + (lineX + X + halfSpace - int(nums[i][0] / 2), lineY - Y - nowf.size - 2), + str(objs[i][1]), + style.textColor, + nowf.ft_font, + ) + + drawEm.line((lineX, lineY, lineX + xSize, lineY), style.textColor, 1) + drawEm.polygon( + [ + (lineX + xSize, lineY), + (lineX + xSize - 3, lineY - 3), + (lineX + xSize - 3, lineY + 3), + ], + style.textColor, + ) + drawEm.line((lineX, lineY - ySize, lineX, lineY), style.textColor, 1) + drawEm.polygon( + [ + (lineX, lineY - ySize), + (lineX - 3, lineY - ySize + 3), + (lineX + 3, lineY - ySize + 3), + ], + style.textColor, + ) + + return exterImage + + +@NewMdExterImageDrawer("card") +def MakeCard( + title: str, + text: str, + xSize: int, + ySize: int, + file: str, + nowf: MixFont, + style = None, +) -> Image.Image: + """创建卡片""" + if xSize < ySize: + raise ValueError("xSize必须比ySize大") + im = Image.open(file) + back = Image.new("RGBA", (xSize, ySize), (0, 0, 0)) + d = ImageDrawPro(back) + + im = im.resize((ySize - 8, ySize - 8)) + d.rectangle((0, 0, xSize, ySize), (27, 26, 85)) + d.rectangle((0, 0, ySize, ySize), (83, 92, 145)) + d.rectangle((2, 2, ySize - 2, ySize - 2), (83, 92, 145)) + + back.paste(im, (4, 4)) + + d.text((ySize + 8, 8), title, (30, 255, 255), nowf) + x, y = ySize + 8, 8 + nowf.size + 8 + for i in text: + s = nowf.GetSize(i) + if x + s[0] > xSize or i == "\n": + x = ySize + 8 + y += nowf.size + 8 + d.text((x, y), i, (255, 255, 255), nowf) + x += s[0] + + return back + + diff --git a/astrbot/core/utils/t2i/pillowmd/mdrenderer.py b/astrbot/core/utils/t2i/pillowmd/mdrenderer.py new file mode 100644 index 000000000..75676e8ac --- /dev/null +++ b/astrbot/core/utils/t2i/pillowmd/mdrenderer.py @@ -0,0 +1,1787 @@ +from PIL import Image +from typing import Optional, Union, Any, List, Tuple +from pathlib import Path +import os +import math +import copy +import pillowlatex +import hashlib +from astrbot.core.utils.io import download_image_by_url +from dataclasses import dataclass, field +from .drawer import DefaultMdBackGroundDraw +from .mixfont import MixFont +from .drawer import ImageDrawPro, extendFuncs +from .style import MdStyle + + + +@dataclass(slots=True) +class MdRenderState: + # --------------- 样式入口(只读) ------------- + style: MdStyle + + # ---------------- 字体 ---------------- + nowf: MixFont # 当前生效字体 + fontK: MixFont # 字体备份(代码块/公式等场景切换后恢复) + + # ---------------- 排版 ---------------- + nmaxX: int = 0 # 当前行最大宽度(像素) + xidx: int = 0 # 当前行内字符序号(从 1 开始) + yidx: int = 1 # 当前行号(从 1 开始) + nx: int = 0 # 当前行已占宽度(像素) + ny: int = 0 # 当前行已占高度(像素) + ys: int = 0 # 当前行总高度(像素) + nmaxh: int = 0 # 当前行最大字符高度(像素) + + hs: List[int] = field(default_factory=list) # 每行高度记录 + maxxs: List[int] = field(default_factory=list) # 每行最大宽度记录 + + # ---------------- 文本 ---------------- + text: str = "" + textS: int = 0 # 文本总长度 + idx: int = -1 # 当前解析到的字符索引 + + # ---------------- 模式开关 ---------------- + bMode: bool = False # 行间公式模式 ($$) + bMode2: bool = False # 行内代码模式 (`) + lMode: bool = False # 删除线模式 (~~) + codeMode: bool = False # 代码块模式 (```) + linkMode: bool = False # 链接模式 + yMode: bool = False # 引用块模式 (>) + textMode: bool = False # 纯文本模式(避免重复进入 Markdown 标记判断) + citeNum: int = 0 # 引用层级深度 + + # ---------------- 表格 ---------------- + forms: List[dict] = field(default_factory=list) # 预解析的表格数据 + formIdx: int = -1 # 当前表格索引 + + # ---------------- 图片 ---------------- + images: List[dict[str, Any]] = field(default_factory=list) # 待绘制图片列表 + isImage: bool = False # 当前字符是否为图片占位 + nowImage: Optional[Image.Image] = None # 当前待绘制图片对象 + + # ---------------- 链接/跳过/颜色 ------ + skips: List[int] = field(default_factory=list) # 需跳过的字符索引(链接、公式等) + linkbegins: List[int] = field(default_factory=list) # 链接开始索引 + linkends: List[int] = field(default_factory=list) # 链接结束索引 + lockColor: Optional[Tuple[int, int, int]] = None # 锁定文字颜色 + colors: List[dict] = field(default_factory=list) # 颜色区间记录 + + # ---------------- 公式 ---------------- + latexs: List[dict] = field(default_factory=list) # 预解析的公式数据 + latexIdx: int = -1 # 当前公式索引 + nowlatexImageIdx: int = -1 # 当前公式图片子索引(多行公式) + + # ---------------- 其他 ---------------- + dr: int = 0 # 列表符号占用高度(用于垂直对齐) + + # --------------- 工厂方法 --------------------- + @classmethod + def create(cls, text: str, style: MdStyle) -> "MdRenderState": + return cls( + text=text, + textS=len(text), + nowf=style.mainFont, + fontK=style.mainFont, + style=style, + ) + + +class PillowMdRenderer: + """ + Markdown → 长图渲染器 + 1. 预解析:扫描文本,记录表格、公式、图片、链接、颜色等区间 + 2. 排版:计算每行宽高、分页 + 3. 绘制:分层绘制背景、特效、文字、图片 + """ + + def __init__(self) -> None: + pass + + @staticmethod + def safe_open_image(path: str): + """ + 如果文件存在且能正常打开,返回 RGBA 的 Image 对象; + """ + try: + if path and os.path.isfile(path): + return Image.open(path).convert("RGBA") + except Exception: + pass + + + @staticmethod + async def open_image_with_cache(imageSrc: str, cache_dir: Path) -> Image.Image: + """ + 根据链接返回 PIL.Image: + 1. 用 md5 做文件名,保留原后缀 + 2. 缓存目录:cache_dir / .cache + 3. 无缓存则下载,下载失败返回 1×1 透明图 + """ + cache_dir.mkdir(parents=True, exist_ok=True) + + # 生成文件名 + suffix = Path(imageSrc).suffix or ".jpg" + safe_name = hashlib.md5(imageSrc.encode()).hexdigest() + suffix + cache_path = cache_dir / safe_name + + try: + if cache_path.exists(): # 命中缓存 + return Image.open(cache_path).convert("RGBA") + # 下载 + tmp_path = await download_image_by_url(imageSrc) + # 移动到缓存 + cache_path.write_bytes(Path(tmp_path).read_bytes()) + return Image.open(cache_path).convert("RGBA") + except Exception: + # 任意异常都降级 + return Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + + @staticmethod + def get_args(args: str) -> tuple[list[Any], dict[str, Any]]: + args += "," + args1 = [] + args2 = {} + pmt = "" + + def _get_one_arg(arg: str): + if arg[0] == "[" and arg[-1] == "]": + args = [] + pmt = "" + deep = 0 + string = False + pre = "" + for i in arg[1:-1] + ",": + if i == "]" and not string: + deep -= 1 + if i == '"' and pre != "\\": + string = not string + + if i == "," and deep == 0 and not string: + args.append(pmt.strip()) + pmt = "" + pre = "" + continue + elif i == "[" and not string: + deep += 1 + + pmt += i + pre = i + return [_get_one_arg(i) for i in args] + if arg[0] == '"' and arg[-1] == '"': + return arg[1:-1] + if arg in ["True", "true"]: + return True + if "." in arg: + return float(arg) + return int(arg) + + deep = 0 + pre = "" + string = False + for i in args: + if i == "]" and not string: + deep -= 1 + + if i == '"' and pre != "\\": + string = not string + + if i == "," and deep == 0 and not string: + pmt = pmt.strip() + if ( + pmt[0] + not in [ + '"', + "[", + ] + and pmt not in ["True", "true", "False", "false"] + and not pmt[0].isdigit() + ): + args2[pmt.split("=")[0].strip()] = "=".join(pmt.split("=")[1:]).strip() + else: + args1.append(pmt) + pmt = "" + pre = "" + continue + elif i == "[" and not string: + deep += 1 + + pmt += i + pre = i + + args1 = [_get_one_arg(i) for i in args1] + for key in args2: + args2[key] = _get_one_arg(args2[key]) + + return (args1, args2) + + # 老接口,新接口开发中 + async def md_to_image( + self, + text: str, + style: MdStyle, + imagePath: Optional[Union[str, Path]] = None, + autoPage: bool | None = None, + ): + """ + 将Markdown转化为图片 + text - 要转化的文本 + style - 风格 + imagePath - 图片相对路径所使用的基路径 + autoPage - 是否自动分页(尽可能接近黄金分割比) + """ + s = style + t = MdRenderState.create(text, style) + imagePath = Path(imagePath) if imagePath else Path(s.images) + autoPage = autoPage if autoPage is not None else s.autoPage + + # ========== 1. 预解析:扫描特殊区间 ========== + while t.idx < t.textS - 1: + t.isImage = False + nowObjH = t.nowf.size + t.idx += 1 + i = text[t.idx] + t.xidx += 1 + + size = t.nowf.GetSize(i) + xs, ys = size[0], size[1] + + # ---- 公式图片占位高度 ---- + if ( + t.latexIdx != -1 + and t.latexs[t.latexIdx]["begin"] < t.idx < t.latexs[t.latexIdx]["end"] + ): + t.nowlatexImageIdx += 1 + + if t.nowlatexImageIdx >= len(t.latexs[t.latexIdx]["images"]): + t.idx = t.latexs[t.latexIdx]["end"] - 1 + t.nowlatexImageIdx = -1 + + continue + else: + space = t.latexs[t.latexIdx]["space"] + i = t.latexs[t.latexIdx]["images"][t.nowlatexImageIdx] + sz = t.latexs[t.latexIdx]["images"][t.nowlatexImageIdx].size + xs, ys = [sz[0], sz[1] + space * 2] + nowObjH = ys + + # ---- 跳过已处理区间 ---- + if t.idx in t.skips: + continue + if t.idx in t.linkends: + continue + + # ---- 行首空格压缩 ---- + if t.xidx == 1 and not t.codeMode and i == " ": + while t.idx < t.textS and text[t.idx] == " ": + t.idx += 1 + t.idx -= 1 + t.xidx = 0 + continue + + # ---- 标题 ---- + if not t.textMode and i == "#" and not t.codeMode: + if t.idx + 1 < t.textS and text[t.idx + 1] == "#": + if t.idx + 2 <= t.textS and text[t.idx + 2] == "#": + t.idx += 2 + t.nowf = s.font1 + else: + t.idx += 1 + t.nowf = s.font2 + else: + t.nowf = s.font3 + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + continue + + # ---- 无序列表 ---- + elif ( + not t.textMode + and i in ["*", "-", "+"] + and t.idx + 1 < t.textS + and text[t.idx + 1] == " " + and not t.codeMode + ): + t.idx += 1 + t.dr = t.nmaxh + t.nx += t.nmaxh + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + continue + + # ---- 有序列表 ---- + elif not t.textMode and i.isdigit() and not t.codeMode: + tempIdx = t.idx - 1 + flag = False + number = "" + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx].isdigit(): + number += text[tempIdx] + elif text[tempIdx] == ".": + flag = True + break + else: + break + if flag: + t.idx = tempIdx + t.nx += 30 + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + continue + t.textMode = True + + # ---- 引用 ---- + elif not t.textMode and i == ">" and not t.codeMode: + t.citeNum = 1 + while t.idx + 1 < t.textS and text[t.idx + 1] == ">": + t.citeNum += 1 + t.idx += 1 + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + t.nx += 30 * (t.citeNum) + 5 + continue + + # ---- 代码块 ---- + elif ( + not t.textMode + and t.idx + 2 <= t.textS + and text[t.idx : t.idx + 3] in ["```", "~~~"] + ): + t.ny += s.codeUb + t.nx += s.codeLb + while t.idx < t.textS - 1 and text[t.idx + 1] != "\n": + t.idx += 1 + if not t.codeMode: + t.fontK = t.nowf + t.nowf = s.fontC + else: + t.nowf = t.fontK + t.codeMode = not t.codeMode + continue + + # ---- 表格 ---- + elif not t.textMode and i == "|" and not t.codeMode: + tempIdx = t.idx - 1 + lText = "" + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + lText += text[tempIdx] + + tempIdx += 1 + lText2 = "" + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + lText2 += text[tempIdx] + + lText = lText.strip() + lText2 = lText.strip() + + temp1 = lText.count("|") + temp2 = lText2.count("|") + exterX = temp1 * s.formLineb + if ( + len(lText) + and len(lText2) + and lText[0] == lText[-1] == lText2[0] == lText2[-1] == "|" + and temp1 == temp2 + and temp1 >= 2 + and exterX < s.maxX + ): + form = [lText.split("|")[1:-1]] + + while True: + preIdx = tempIdx + tempIdx += 1 + tempText = "" + + if tempIdx + 1 >= t.textS or text[tempIdx + 1] != "|": + break + + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + tempText += text[tempIdx] + + temp = tempText.count("|") + if not (tempText[0] == tempText[-1] == "|" and temp >= 2): + tempIdx = preIdx + break + + form.append(tempText.split("|")[1:-1]) + + formHeadNum = len(form[0]) + formSize = [] + for ii in range(len(form)): + formNowLNum = len(form[ii]) + + if formNowLNum < formHeadNum: + form[ii] = form[ii] + [""] * (formHeadNum - formNowLNum) + if formNowLNum > formHeadNum: + form = form[0:formHeadNum] + + formSize.append( + [ + sum([s.mainFont.GetSize(j)[0] for j in i]) + for i in form[ii] + ] + ) + + formRow = len(form) + colunmSizes = [[0, 99999]] + sorted( + [ + [max([formSize[deep][i] for deep in range(formRow)]), i] + for i in range(formHeadNum) + ], + key=lambda x: x[0], + ) + maxIdx = len(colunmSizes) - 1 + + if not (s.mainFont.size * len(colunmSizes) + exterX > s.maxX): + while sum([i[0] for i in colunmSizes]) + exterX > s.maxX: + exceed = sum([i[0] for i in colunmSizes]) + exterX - s.maxX + sizeIdx = len(colunmSizes) - 1 + + while ( + colunmSizes[sizeIdx - 1][0] == colunmSizes[sizeIdx][0] + ): + sizeIdx -= 1 + + temp = math.ceil( + min( + exceed / (maxIdx - sizeIdx + 1), + colunmSizes[sizeIdx][0] + - colunmSizes[sizeIdx - 1][0], + ) + ) + for ii in range(sizeIdx, maxIdx + 1): + colunmSizes[ii][0] = colunmSizes[ii][0] - temp + + colunmSizes = [ + i[0] for i in sorted(colunmSizes[1:], key=lambda x: x[1]) + ] + rowSizes = [] + + for ii in range(formRow): + nMaxRowSize = 0 + + for j in range(formHeadNum): + tempRowSize = s.mainFont.size + formNx = 0 + formTextIdx = -1 + formText = form[ii][j] + formTextSize = len(formText) + + while formTextIdx + 1 < formTextSize: + formTextIdx += 1 + char = formText[formTextIdx] + formCharX = s.mainFont.GetSize(char)[0] + + if formNx + formCharX > colunmSizes[j]: + tempRowSize += s.mainFont.size + s.lineb + formNx = 0 + + formNx += formCharX + + nMaxRowSize = max(nMaxRowSize, tempRowSize) + + rowSizes.append(nMaxRowSize) + + t.forms.append( + { + "height": (formRow) * s.formLineb + + sum(rowSizes) + + s.formLineb, + "width": sum(colunmSizes) + exterX, + "rowSizes": copy.deepcopy(rowSizes), + "colunmSizes": copy.deepcopy(colunmSizes), + "form": copy.deepcopy(form), + "endIdx": tempIdx, + "beginIdx": t.idx, + } + ) + t.ny += s.lineb * (tempIdx < t.textS) + t.forms[-1]["height"] + t.ys = 0 + t.idx = tempIdx + t.nmaxX = max(t.nmaxX, sum(colunmSizes) + exterX) + continue + + # ---- 普通文字 ---- + else: + t.textMode = True + + # ---- 行内公式/代码/图片/链接/颜色等 ---- + if ( + i == "*" + and t.idx + 1 < t.textS + and text[t.idx + 1] == "*" + and not t.codeMode + ): + t.idx += 1 + continue + if ( + i == "~" + and t.idx + 1 < t.textS + and text[t.idx + 1] == "~" + and not t.codeMode + ): + t.idx += 1 + continue + + if ( + i == "$" + and (text[t.idx - 1] != "\\" if t.idx >= 1 else True) + and (t.idx + 1 < t.textS and text[t.idx + 1] == "$") + and not t.codeMode + and not t.bMode2 + ): + tempIdx = t.idx + flag = False + while tempIdx < t.textS - 1: + tempIdx += 1 + if ( + text[tempIdx] == "$" + and tempIdx + 1 < t.textS + and text[tempIdx + 1] == "$" + ): + flag = True + break + if flag or t.bMode: + t.nx += 2 + if not t.bMode: + if t.xidx != 1: + t.nmaxX = max(t.nx, t.nmaxX) + t.maxxs.append(t.nx) + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + t.xidx = 0 + t.yidx += 1 + t.hs.append(t.nmaxh) + t.nmaxh = int(s.fontC.size / 3) + t.citeNum = 0 + t.dr = 0 + + t.fontK = t.nowf + t.nowf = s.get_gfont(t.nowf) + lateximgs = pillowlatex.RenderLaTeXObjs( + pillowlatex.GetLaTeXObjs(text[t.idx + 2 : tempIdx]), + font=MixFont.MixFontToLatexFont(t.nowf), + color=s.expressionTextColor, + ) + t.latexs.append( + { + "begin": t.idx + 1, + "end": tempIdx, + "images": lateximgs, + "maxheight": max([i.height for i in lateximgs]) + if lateximgs + else t.nowf.size, + "space": pillowlatex.settings.SPACE, + "super": True, + } + ) + t.latexIdx += 1 + t.nowlatexImageIdx = -1 + else: + t.nmaxX = max(t.nx, t.nmaxX) + t.maxxs.append(t.nx) + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + t.xidx = 0 + t.yidx += 1 + t.hs.append(t.nmaxh) + t.nmaxh = int(s.fontC.size / 3) + t.citeNum = 0 + t.dr = 0 + + t.nowf = t.fontK + t.bMode = not t.bMode + t.idx += 1 + continue + + if ( + i == "$" + and (text[t.idx - 1] != "\\" if t.idx >= 1 else True) + and not t.codeMode + and not t.bMode2 + ): + tempIdx = t.idx + flag = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == "$": + flag = True + break + if flag or t.bMode: + t.nx += 2 + if not t.bMode: + t.fontK = t.nowf + t.nowf = s.get_gfont(t.nowf) + lateximgs = pillowlatex.RenderLaTeXObjs( + pillowlatex.GetLaTeXObjs(text[t.idx + 1 : tempIdx]), + font=MixFont.MixFontToLatexFont(t.nowf), + color=s.expressionTextColor, + ) + t.latexs.append( + { + "begin": t.idx, + "end": tempIdx, + "images": lateximgs, + "maxheight": max([i.height for i in lateximgs]) + if lateximgs + else t.nowf.size, + "space": pillowlatex.settings.SPACE, + "super": False, + } + ) + t.latexIdx += 1 + t.nowlatexImageIdx = -1 + else: + t.nowf = t.fontK + t.bMode = not t.bMode + continue + + if ( + i == "`" + and (text[t.idx - 1] != "\\" if t.idx >= 1 else True) + and not t.codeMode + and not t.bMode + ): + if not ( + t.xidx == 1 + and t.idx + 2 <= t.textS + and text[t.idx : t.idx + 3] == "```" + ): + tempIdx = t.idx + flag = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == "`": + flag = True + break + if flag or t.bMode2: + t.nx += 2 + if not t.bMode2: + t.fontK = t.nowf + t.nowf = s.get_gfont(t.nowf) + else: + t.nowf = t.fontK + t.bMode2 = not t.bMode2 + continue + + if ( + i == "!" + and t.idx + 9 < t.textS + and text[t.idx : t.idx + 9] == "!sgexter[" + and not t.codeMode + and not t.bMode + ): + tempIdx = t.idx + 8 + flag = False + data = "" + + deep = 0 + string = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + + if text[tempIdx] == '"' and text[tempIdx - 1] == "\\": + string = not string + + if text[tempIdx] == "[" and not string: + deep += 1 + + if text[tempIdx] == "]" and not string: + if deep == 0: + flag = True + break + + deep -= 1 + + data += text[tempIdx] + + if flag: + flag = False + + try: + args = data.split(",") + funcName = args[0] + args = ",".join(args[1:]) + flag = True + except Exception: + pass + + if flag and funcName in extendFuncs: + flag = False + + try: + args1, args2 = self.get_args(args) # type: ignore + flag = True + except Exception: + pass + + if flag: + idata = { + "image": extendFuncs[funcName]( + *args1, + **args2, + nowf=t.nowf, + style=s, + lockColor=t.lockColor, + ), + "begin": t.idx, + "end": tempIdx, + } + t.images.append(idata) + t.isImage = True + xs, ys = idata["image"].size + nowObjH = ys + t.idx = tempIdx + + + if ( + i == "!" + and t.idx + 1 < t.textS + and text[t.idx : t.idx + 2] == "![" + and not t.codeMode + and not t.bMode + ): + imageName = "" + imageSrc = "" + tempIdx = t.idx + 1 + try: + flag = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == "]": + flag = True + break + imageName += text[tempIdx] + if not flag: + raise ValueError("错误: 图片解析失败") + tempIdx += 1 + flag = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == ")": + flag = True + break + imageSrc += text[tempIdx] + if not flag: + raise ValueError("错误: 图片解析失败") + imageSrc = imageSrc.split(" ")[0] + image = await self.open_image_with_cache(imageSrc, imagePath) + idata = {"image": image, "begin": t.idx, "end": tempIdx} + t.images.append(idata) + if idata["image"].size[0] > s.maxX: + idata["image"] = idata["image"].resize( + ( + int(s.maxX), + int( + idata["image"].size[1] + * (s.maxX / idata["image"].size[0]) + ), + ) + ) + t.isImage = True + xs, ys = idata["image"].size + nowObjH = ys + t.idx = tempIdx + except Exception as e: + print(e) + t.skips += list(range(t.idx + len(imageName) + 2, tempIdx)) + t.linkbegins.append(t.idx) + t.linkends.append(tempIdx) + continue + + if i == "[" and not t.codeMode and not t.bMode: + tempIdx = t.idx + linkName = "" + link = "" + flag = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == "]": + flag = True + break + linkName += text[tempIdx] + flag = False + tempIdx += 1 + if tempIdx + 1 <= t.textS and text[tempIdx] == "(": + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == ")": + flag = True + break + link += text[tempIdx] + if flag: + t.skips.append(t.idx + len(linkName) + 2) + t.linkbegins.append(t.idx) + t.linkends.append(t.idx + len(linkName) + 2) + for k in range(t.idx + len(linkName) + 3, tempIdx): + t.skips.append(k) + t.skips.append(tempIdx) + + if ( + i == "<" + and t.idx + 6 < t.textS + and text[t.idx + 1 : t.idx + 7] == "color=" + ): + color = "" + flag = False + tempIdx = t.idx + 6 + k = 0 + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + k += 1 + if k >= 10: + break + tempIdx += 1 + if text[tempIdx] == ">": + flag = True + break + color += text[tempIdx] + + if flag: + if (len(color) == 7 and color[0] == "#") or color == "None": + t.lockColor = None if color == "None" else color # type ignored + t.colors.append( + {"beginIdx": t.idx, "endIdx": tempIdx, "color": t.lockColor} + ) + t.idx = tempIdx + continue + + ex = 0 + preNmaxh = max(t.nmaxh, nowObjH) + if t.dr and min(preNmaxh, s.font3.size) > t.dr: + ex += min(preNmaxh, s.font3.size) - t.dr + t.dr = min(preNmaxh, s.font3.size) + + if i == "\n": + t.nmaxX = max(t.nx, t.nmaxX) + t.maxxs.append(t.nx) + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + t.xidx = 0 + t.yidx += 1 + t.hs.append(t.nmaxh) + t.nmaxh = int(s.fontC.size / 3) + t.textMode = False + t.citeNum = 0 + if not t.codeMode: + t.nowf = s.mainFont + t.dr = 0 + continue + if t.nx + xs + ex > s.maxX: + t.nmaxX = max(t.nx, t.nmaxX) + t.maxxs.append(t.nx) + t.yidx += 1 + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + if t.citeNum: + t.nx += 30 * (t.citeNum - 1) + 5 + t.hs.append(t.nmaxh) + t.nmaxh = int(s.fontC.size) + t.dr = 0 + + t.nx += int(xs + ex) + t.nmaxh = int(max(t.nmaxh, nowObjH)) + + # ========== 2. 计算分页 & 画布尺寸 ========== + + t.nmaxX = max(t.nx, t.nmaxX) + t.nmaxh = max(t.nmaxh, t.ys) + t.ny += t.nmaxh + t.maxxs.append(t.nx) + t.hs.append(t.nmaxh) + + paintImage = self.safe_open_image(s.paintPath) + + page = 1 + if autoPage: + while True: + bX = (t.nmaxX + s.rb + s.lb) * page + bY = int(t.ny / page) + s.ub + s.db + if bY > 300 and paintImage: + txs, tys = bX, bY + + if tys < txs * 2.5: + bX += int( + paintImage.size[0] / paintImage.size[1] * (bY - s.ub - s.db) + ) + eX = (t.nmaxX + s.rb + s.lb) * (page + 1) + eY = int(t.ny / (page + 1)) + s.ub + s.db + if eY > 300 and paintImage: + txs, tys = eX, eY + + if tys < txs * 2.5: + eX += int( + paintImage.size[0] / paintImage.size[1] * (eY - s.ub - s.db) + ) + if abs(min(bX, bY) / max(bX, bY) - 0.618) < abs( + min(eX, eY) / max(eX, eY) - 0.618 + ): + break + page += 1 + + if page > len(t.hs): + page = len(t.hs) + + txs, tys = (t.nmaxX + s.rb + s.lb) * page, int(t.ny / page) + + yTys = tys + + temp = 0 + temp2 = tys + temp3 = tys + for ys in t.hs: + temp += ys + + if temp > yTys: + temp2 = max(temp2, temp + 1) + temp = 0 + continue + + temp += s.lineb + + temp = 0 + for ys in t.hs[-1::-1]: + temp += ys + + if temp > yTys: + temp3 = max(temp3, temp + 1) + temp = 0 + continue + + temp += s.lineb + + tys = min(temp2, temp3) + + tys = int(tys) + + PYL = tys + 1 + tys += s.ub + s.db + tlb = s.lb + + bt = False + + if tys > 300 and tys < txs * 2.5 and paintImage: + bt = True + temp = int(tys - s.ub - s.db) + paintImage = paintImage.resize( + (int(paintImage.size[0] / paintImage.size[1] * temp), temp) + ).convert("RGBA") + txs += paintImage.size[0] + + t.lockColor = None + + # ========== 3. 创建画布 & 分层绘制 ========== + + if s.decorates: + outImage = s.decorates.Draw(int(txs), tys) + else: + outImage = DefaultMdBackGroundDraw(int(txs), tys) + + imgEffect = Image.new("RGBA", (int(txs), tys), color=(0, 0, 0, 0)) + imgText = Image.new("RGBA", (int(txs), tys), color=(0, 0, 0, 0)) + imgImages = Image.new("RGBA", (int(txs), tys), color=(0, 0, 0, 0)) + + drawEffect = ImageDrawPro(imgEffect) + draw = ImageDrawPro(imgText) + + # 分页线 + for i in range(1, page): + lx = (t.nmaxX + s.rb + s.lb) * i + lby = s.ub + ley = tys - s.db + lwidth = int(min(s.lb, s.rb) / 6) * 2 + match s.pageLineStyle: + case "full_line": + drawEffect.line((lx, lby, lx, ley), s.pageLineColor, lwidth) + case "dotted_line": + for nly in range(lby, ley, lwidth * 8): + drawEffect.line( + (lx, nly, lx, nly + lwidth * 5), s.pageLineColor, lwidth + ) + + # 重置状态,准备绘制 + t.xidx = 0 + t.yidx = 1 + t.nx = 0 + t.ny = 0 + t.nmaxh = 0 + t.nowf = s.mainFont + hMode = False + t.bMode = False + t.bMode2 = False + lMode = False + t.yMode = False + t.codeMode = False + t.citeNum = 0 + t.textMode = False + + # 绘制函数闭包 + def ChangeLockColor(color) -> None: + nonlocal t + t.lockColor = color + draw.text_lock_color = color + + def ChangeLinkMode(mode: bool) -> None: + nonlocal t + t.linkMode = mode + draw.under_line_mode = mode + + def ChangeDeleteLineMode(mode: bool) -> None: + nonlocal lMode, t + lMode = mode + draw.delete_line_mode = mode + + def ChangeBlodMode(mode: bool) -> None: + nonlocal hMode, t + hMode = mode + draw.text_blod_mode = mode + + # ========== 4. 逐字绘制 ========== + + t.nowlatexImageIdx = -1 + imageIdx = -1 + islatex = False + + t.idx: int = -1 + while t.idx < t.textS - 1: + t.isImage = False + nowObjH = t.nowf.size + t.idx += 1 + i = text[t.idx] + t.xidx += 1 + size = t.nowf.GetSize(i) + xs, ys = size[0], size[1] + + islatex = False + + # 公式 + if t.latexs and t.latexs[0]["begin"] < t.idx < t.latexs[0]["end"]: + t.nowlatexImageIdx += 1 + if t.nowlatexImageIdx >= len(t.latexs[0]["images"]): + t.idx = t.latexs[0]["end"] - 1 + t.nowlatexImageIdx = -1 + del t.latexs[0] + continue + else: + islatex = True + space = t.latexs[0]["space"] + i = t.latexs[0]["images"][t.nowlatexImageIdx] + sz = t.latexs[0]["images"][t.nowlatexImageIdx].size + xs, ys = [sz[0], sz[1] + space * 2] + nowObjH = ys + + # 跳过 + if t.idx in t.skips: + if t.idx in t.linkends: + ChangeLinkMode(False) + continue + if t.idx in t.linkbegins: + ChangeLinkMode(True) + if t.idx in t.linkends: + ChangeLinkMode(False) + continue + + # 行首空格 + if t.xidx == 1 and not t.codeMode and i == " ": + while t.idx < t.textS and text[t.idx] == " ": + t.idx += 1 + t.idx -= 1 + t.xidx = 0 + continue + + # 标题 + if not t.textMode and i == "#" and not t.codeMode: + if t.idx + 1 < t.textS and text[t.idx + 1] == "#": + if t.idx + 2 <= t.textS and text[t.idx + 2] == "#": + t.idx += 2 + t.nowf = s.font1 + else: + t.idx += 1 + t.nowf = s.font2 + else: + t.nowf = s.font3 + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + continue + + # 无序列表 + elif ( + not t.textMode + and i in ["*", "-", "+"] + and t.idx + 1 < t.textS + and text[t.idx + 1] == " " + and not t.codeMode + ): + t.idx += 1 + h = min(t.hs[t.yidx - 1], s.font3.size) + sh = int(h / 6) + zx, zy = s.lb + t.nx + int(h / 2), s.ub + t.ny + int(h / 2) + 1 + draw.polygon( + [(zx - sh, zy), (zx, zy - sh), (zx + sh, zy), (zx, zy + sh)], + s.unorderedListDotColor, + ) + t.nx += int(h) + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + continue + + # 有序列表 + elif not t.textMode and i.isdigit() and not t.codeMode: + tempIdx = t.idx - 1 + flag = False + number = "" + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx].isdigit(): + number += text[tempIdx] + elif text[tempIdx] == ".": + flag = True + break + else: + break + if flag: + t.idx = tempIdx + h = t.hs[t.yidx - 1] + sh = int(s.codeBlockFontSize * 0.67) + zx, zy = ( + s.lb + t.nx + int(h / 2), + s.ub + t.ny + int(t.hs[t.yidx - 1] / 2) + 1, + ) + draw.polygon( + [(zx - sh, zy), (zx, zy - sh), (zx + sh, zy), (zx, zy + sh)], + s.orderedListDotColor, + ) + sz = s.fontC.GetSize(number) + draw.text( + (zx - int((sz[0] - 1) / 2), zy - int(s.fontC.size / 2) - 1), + number, + s.orderedListNumberColor, + s.fontC, + ) + t.nx += h + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + continue + else: + t.textMode = True + + # 引用 + elif not t.textMode and i == ">" and not t.codeMode: + t.citeNum = 1 + while t.idx + 1 < t.textS and text[t.idx + 1] == ">": + t.citeNum += 1 + t.idx += 1 + if not t.yMode: + drawEffect.rectangle( + ( + s.lb + t.nx, + s.ub + t.ny - s.lineb // 2, + s.lb + t.nx + t.nmaxX, + s.ub + t.ny + t.hs[t.yidx - 1] + s.lineb // 2, + ), + s.citeUnderpainting, + ) + for k in range(t.citeNum): + drawEffect.line( + ( + s.lb + t.nx + s.citeb * (k), + s.ub + t.ny - s.lineb // 2, + s.lb + t.nx + s.citeb * (k), + s.ub + t.ny + t.hs[t.yidx - 1] + s.lineb // 2, + ), + s.citeSplitLineColor, + 5, + ) + t.nx += s.citeb * (t.citeNum) + 5 + t.yMode = True + t.xidx -= 1 + while t.idx + 1 < t.textS and text[t.idx + 1] == " ": + t.idx += 1 + continue + + # 代码块 + elif ( + not t.textMode + and t.idx + 2 <= t.textS + and text[t.idx : t.idx + 3] in ["```", "~~~"] + ): + name = "" + while t.idx < t.textS - 1 and text[t.idx + 1] != "\n": + t.idx += 1 + name += text[t.idx] + drawEffect.rectangle( + ( + s.lb, + s.ub + t.ny, + s.lb + t.nmaxX, + s.ub + t.ny + s.codeUb + s.fontC.size, + ), + s.codeBlockUnderpainting, + ) + draw.text( + (s.lb + s.codeLb + 2, s.ub + t.ny), + name[2:], + s.codeBlockTitleColor, + s.fontC, + ) + if not t.codeMode: + t.fontK = t.nowf + t.nowf = s.fontC + else: + t.nowf = t.fontK + drawEffect.rectangle( + ( + s.lb, + s.ub + t.ny - s.lineb, + s.lb + t.nmaxX, + s.ub + t.ny + s.codeUb, + ), + s.codeBlockUnderpainting, + ) + t.ny += s.codeUb + t.nx += s.codeLb + t.codeMode = not t.codeMode + continue + + # 表格 + elif ( + not t.textMode + and i == "|" + and t.formIdx + 1 < len(t.forms) + and t.forms[t.formIdx + 1]["beginIdx"] == t.idx + and not t.codeMode + ): + t.formIdx += 1 + formData = t.forms[t.formIdx] + form = formData["form"] + rowSizes = formData["rowSizes"] + colunmSizes = formData["colunmSizes"] + formHeight = formData["height"] + formWidth = formData["width"] + t.idx = formData["endIdx"] + # ny += lineSpace + halfFormLineSpace: int = s.formLineb // 2 + exterNum = 0 + bx, by = ( + int(s.lb + halfFormLineSpace), + s.ub + t.ny + exterNum + halfFormLineSpace, + ) + + # 表格背景 + draw.rectangle( + ( + bx, + by, + int(s.lb - halfFormLineSpace + formWidth), + s.ub + + t.ny + + int(halfFormLineSpace) + + s.formLineb * len(rowSizes) + + sum(rowSizes), + ), + s.formUnderpainting, + ) + + # 表头背景 + draw.rectangle( + ( + bx, + by, + int(bx - halfFormLineSpace * 2 + formWidth), + by + rowSizes[0] + s.formLineb, + ), + s.formTitleUnderpainting, + ) + + # 横线 + for num in rowSizes: + draw.line( + ( + int(s.lb + halfFormLineSpace), + s.ub + t.ny + int(halfFormLineSpace) + exterNum, + int(s.lb - halfFormLineSpace + formWidth), + s.ub + t.ny + int(halfFormLineSpace) + exterNum, + ), + s.formLineColor, + 2, + ) + exterNum += num + s.formLineb + draw.line( + ( + int(s.lb + halfFormLineSpace), + s.ub + t.ny + int(halfFormLineSpace) + exterNum, + int(s.lb - halfFormLineSpace + formWidth), + s.ub + t.ny + int(halfFormLineSpace) + exterNum, + ), + s.formLineColor, + 2, + ) + + # 竖线 + exterNum = 0 + for num in colunmSizes: + draw.line( + ( + int(s.lb + halfFormLineSpace) + exterNum, + s.ub + t.ny + int(halfFormLineSpace), + int(s.lb + halfFormLineSpace) + exterNum, + s.ub + t.ny + int(formHeight - halfFormLineSpace), + ), + s.formLineColor, + 2, + ) + exterNum += num + s.formLineb + draw.line( + ( + int(s.lb + halfFormLineSpace) + exterNum, + s.ub + t.ny + int(halfFormLineSpace), + int(s.lb + halfFormLineSpace) + exterNum, + s.ub + t.ny + int(formHeight - halfFormLineSpace), + ), + s.formLineColor, + 2, + ) + + # 单元格文字 + formRow = len(form) + formHeadNum = len(form[0]) + + formTextX = s.formLineb + formTextY = s.formLineb + + for ii in range(formRow): + formTextX = s.formLineb + + for j in range(formHeadNum): + formNx = 0 + formNy = 0 + formTextIdx = -1 + formText = form[ii][j] + formTextSize = len(formText) + + while formTextIdx + 1 < formTextSize: + formTextIdx += 1 + char = formText[formTextIdx] + formCharX = s.mainFont.GetSize(char)[0] + + if formNx + formCharX > colunmSizes[j]: + formNx = 0 + formNy += s.mainFont.size + + draw.text( + ( + s.lb + formTextX + formNx, + s.ub + t.ny + formTextY + formNy, + ), + char, + s.formTextColor, + s.mainFont, + ) + + formNx += formCharX + + formTextX += colunmSizes[j] + s.formLineb + + formTextY += rowSizes[ii] + s.formLineb + t.ny += s.lineb * (formData["endIdx"] < t.textS) + formHeight + continue + else: + t.textMode = True + + # 颜色 + if len(t.colors) and t.colors[0]["beginIdx"] == t.idx: + ChangeLockColor(t.colors[0]["color"]) + t.idx = t.colors[0]["endIdx"] + del t.colors[0] + continue + + # 加粗 + if ( + i == "*" + and t.idx + 1 < t.textS + and text[t.idx + 1] == "*" + and not t.codeMode + ): + t.idx += 1 + ChangeBlodMode(not hMode) + continue + + # 删除线 + if ( + i == "~" + and t.idx + 1 < t.textS + and text[t.idx + 1] == "~" + and not t.codeMode + ): + t.idx += 1 + ChangeDeleteLineMode(not lMode) + continue + + # 行内公式 + if ( + i == "$" + and (text[t.idx - 1] != "\\" if t.idx >= 1 else True) + and (t.idx + 1 < t.textS and text[t.idx + 1] == "$") + and not t.codeMode + and not t.bMode2 + ): + tempIdx = t.idx + flag = False + while tempIdx < t.textS - 1: + tempIdx += 1 + if ( + text[tempIdx] == "$" + and tempIdx + 1 < t.textS + and text[tempIdx + 1] == "$" + ): + flag = True + break + if flag or t.bMode: + if not t.bMode: + if t.xidx != 1: + t.nmaxX = max(t.nx, t.nmaxX) + t.maxxs.append(t.nx) + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + t.xidx = 0 + t.yidx += 1 + t.hs.append(t.nmaxh) + t.nmaxh = int(s.fontC.size / 3) + t.citeNum = 0 + t.dr = 0 + + t.fontK = t.nowf + t.nowf = s.get_gfont(t.nowf) + fs = t.nowf.size + + xbase = t.nmaxX // 2 - t.maxxs[t.yidx - 1] // 2 + + drawEffect.rectangle( + ( + xbase + s.lb + t.nx - 1, + s.ub + t.ny, + xbase + s.lb + t.nx + 1, + s.ub + t.ny + t.hs[t.yidx - 1], + ), + s.expressionUnderpainting, + ) + + else: + xbase = t.nmaxX // 2 - t.maxxs[t.yidx - 1] // 2 + + drawEffect.rectangle( + ( + xbase + s.lb + t.nx - 1, + s.ub + t.ny, + xbase + s.lb + t.nx + 1, + s.ub + t.ny + t.hs[t.yidx - 1], + ), + s.expressionUnderpainting, + ) + + t.nmaxX = max(t.nx, t.nmaxX) + t.maxxs.append(t.nx) + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + t.xidx = 0 + t.yidx += 1 + t.hs.append(t.nmaxh) + t.nmaxh = int(s.fontC.size / 3) + t.citeNum = 0 + t.dr = 0 + + fs = t.nowf.size + t.nowf = t.fontK + + t.bMode = not t.bMode + + t.nx += 2 + t.idx += 1 + continue + + if ( + i == "$" + and (text[t.idx - 1] != "\\" if t.idx >= 1 else True) + and not t.codeMode + and not t.bMode2 + ): + tempIdx = t.idx + flag = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == "$": + flag = True + break + if flag or t.bMode: + if not t.bMode: + t.fontK = t.nowf + t.nowf = s.get_gfont(t.nowf) + fs = t.nowf.size + else: + fs = t.nowf.size + t.nowf = t.fontK + t.bMode = not t.bMode + # zx,zy = lb+nx,ub+ny+hs[yidx-1] + drawEffect.rectangle( + ( + s.lb + t.nx - 1, + s.ub + t.ny, + s.lb + t.nx + 1, + s.ub + t.ny + t.hs[t.yidx - 1], + ), + s.expressionUnderpainting, + ) + t.nx += 2 + continue + + # 行内代码 + if ( + i == "`" + and (text[t.idx - 1] != "\\" if t.idx >= 1 else True) + and not t.codeMode + and not t.bMode + ): + if not ( + t.xidx == 1 + and t.idx + 2 <= t.textS + and text[t.idx : t.idx + 3] == "```" + ): + tempIdx = t.idx + flag = False + while tempIdx < t.textS - 1 and text[tempIdx + 1] != "\n": + tempIdx += 1 + if text[tempIdx] == "`": + flag = True + break + if flag or t.bMode2: + if not t.bMode2: + t.fontK = t.nowf + t.nowf = s.get_gfont(t.nowf) + fs = t.nowf.size + else: + fs = t.nowf.size + t.nowf = t.fontK + t.bMode2 = not t.bMode2 + zx, zy = s.lb + t.nx, s.ub + t.ny + t.hs[t.yidx - 1] + draw.rectangle( + (zx, zy - fs - 2, zx + 2, zy), s.insertCodeUnderpainting + ) + t.nx += 2 + continue + + # 普通图片 ![alt](src) + if ( + imageIdx + 1 < len(t.images) + and t.idx == t.images[imageIdx + 1]["begin"] + ): + imageIdx += 1 + t.idx = t.images[imageIdx]["end"] + t.nowImage = t.images[imageIdx]["image"] + t.isImage = True + xs, ys = t.nowImage.size # type: ignore + nowObjH = ys + + # 代码块背景 + if t.xidx == 1 and t.codeMode: + drawEffect.rectangle( + ( + s.lb, + s.ub + t.ny - s.lineb, + s.lb + t.nmaxX, + s.ub + t.ny + t.nowf.size, + ), + s.codeBlockUnderpainting, + ) + + # 换行 + if i == "\n": + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + t.xidx = 0 + t.yidx += 1 + if t.ny + t.hs[t.yidx - 1] > PYL: + t.ny = 0 + s.lb += tlb + s.rb + t.nmaxX + t.nmaxh = int(s.fontC.size / 3) + t.textMode = False + t.citeNum = 0 + if ( + t.nowf != s.mainFont + and t.nowf not in [s.fontG, s.font1G, s.font2G, s.font3G] + and not t.codeMode + ): + draw.line( + (s.lb, s.ub + t.ny - 2, s.lb + t.nmaxX, s.ub + t.ny - 2), + s.idlineColor, + ) + if not t.codeMode: + t.nowf = s.mainFont + t.yMode = False + continue + + # 自动换行 + if t.nx + xs > s.maxX: + t.nx = t.codeMode * s.codeLb + t.ny += t.nmaxh + s.lineb + t.yidx += 1 + t.nmaxh = int(s.fontC.size) + try: + if t.ny + t.hs[t.yidx - 1] > PYL: + t.ny = 0 + s.lb += tlb + s.rb + t.nmaxX + except Exception: + pass + if t.citeNum: + t.nx += s.citeb * (t.citeNum - 1) + 5 + if t.yMode: + drawEffect.rectangle( + ( + s.lb, + s.ub + t.ny - s.lineb // 2, + s.lb + t.nmaxX, + s.ub + t.ny + t.hs[t.yidx - 1] + s.lineb // 2, + ), + s.citeUnderpainting, + ) + for k in range(t.citeNum - 1): + drawEffect.line( + ( + s.lb + s.citeb * (k + 1), + s.ub + t.ny - s.lineb // 2, + s.lb + s.citeb * (k + 1), + s.ub + t.ny + t.hs[t.yidx - 1] + s.lineb // 2, + ), + s.citeSplitLineColor, + 5, + ) + + # 文字颜色 + b = s.title1FontSize - s.fontSize + normalColor = tuple( + int( + s.textColor[i] + + (s.textGradientEndColor[i] - s.textColor[i]) + / b + * (t.nowf.size - s.fontSize) + ) + for i in range(min(len(s.textColor), len(s.textGradientEndColor))) + ) + if t.linkMode: + normalColor = s.linkColor + + if islatex: + xbase = 0 + + if t.latexs[0]["super"]: + xbase = t.nmaxX // 2 - t.maxxs[t.yidx - 1] // 2 + else: + xbase = 0 + img: pillowlatex.LaTeXImage = t.latexs[0]["images"][t.nowlatexImageIdx] + drawEffect.rectangle( + ( + s.lb + t.nx + xbase, + s.ub + t.ny, + s.lb + t.nx + img.width + xbase, + s.ub + t.ny + t.hs[t.yidx - 1], + ), + s.expressionUnderpainting, + ) + imgText.alpha_composite( + img.img, + ( + s.lb + t.nx - img.space + xbase, + s.ub + t.ny + (t.hs[t.yidx - 1] - img.height) // 2 - img.space, + ), + ) + elif t.isImage and isinstance(t.nowImage, Image.Image): + imgImages.alpha_composite( + t.nowImage.convert("RGBA"), + ( + int(s.lb + t.nx), + s.ub + t.ny + t.hs[t.yidx - 1] - t.nowImage.size[1], + ), + ) + elif t.bMode or t.bMode2: + if t.bMode: + drawEffect.rectangle( + ( + s.lb + t.nx, + s.ub + t.ny + t.hs[t.yidx - 1] - t.nowf.size - 2, + s.lb + t.nx + xs, + s.ub + t.ny + t.hs[t.yidx - 1], + ), + s.expressionUnderpainting, + ) + draw.text( + (s.lb + t.nx, s.ub + t.ny + t.hs[t.yidx - 1] - t.nowf.size - 2), + i, + s.expressionTextColor, + t.nowf, + ) + elif t.bMode2: + drawEffect.rectangle( + ( + s.lb + t.nx, + s.ub + t.ny + t.hs[t.yidx - 1] - t.nowf.size - 2, + s.lb + t.nx + xs, + s.ub + t.ny + t.hs[t.yidx - 1], + ), + s.insertCodeUnderpainting, + ) + draw.text( + (s.lb + t.nx, s.ub + t.ny + t.hs[t.yidx - 1] - t.nowf.size - 2), + i, + s.insertCodeTextColor, + t.nowf, + ) + elif t.codeMode: + draw.text( + (s.lb + t.nx, s.ub + t.ny + t.hs[t.yidx - 1] - t.nowf.size - 2), + i, + s.codeBlockTextColor, + t.nowf, + **dict.fromkeys( + [ + "use_under_line_mode", + "use_delete_line_mode", + "use_blod_mode", + ], + False, + ), + ) + else: + draw.text( + (s.lb + t.nx, s.ub + t.ny + t.hs[t.yidx - 1] - t.nowf.size), + i, + normalColor, + t.nowf, + ) + + t.xidx += 1 + t.nx += xs + t.nmaxh = int(max(t.nmaxh, nowObjH)) + + ChangeLockColor(None) + ChangeBlodMode(False) + ChangeDeleteLineMode(False) + ChangeLinkMode(False) + + imgEffect.alpha_composite(imgText) + imgEffect.alpha_composite(imgImages) + + + outImage.alpha_composite(imgEffect) + + if bt and paintImage: + outImage.alpha_composite( + paintImage, + (int(txs - s.rb - paintImage.size[0]), tys - paintImage.size[1] - s.db), + ) + + if s.decorates: + outImage.alpha_composite(s.decorates.DrawTop(int(txs), tys)) + + return outImage diff --git a/astrbot/core/utils/t2i/pillowmd/mixfont.py b/astrbot/core/utils/t2i/pillowmd/mixfont.py new file mode 100644 index 000000000..73e82588e --- /dev/null +++ b/astrbot/core/utils/t2i/pillowmd/mixfont.py @@ -0,0 +1,97 @@ +import os +from typing import Dict +from PIL import ImageFont +from PIL.ImageFont import FreeTypeFont +from fontTools.ttLib import TTFont +import pillowlatex +from pathlib import Path + + +class MixFont: + """混合字体类""" + + _size_cache: Dict["MixFont", Dict[str, tuple[int, int]]] = {} + _font_cache: Dict[str, "MixFont"] = {} + _latex_font_cache: Dict[str, pillowlatex.MixFont] = {} + + def __init__(self, path: str, size: int = 10) -> None: + # 字体路径 + self.path = os.path.abspath(path) + # 字体名称 + self.name = os.path.basename(self.path) + # 字体大小 + self.size = size + # 字体的 FreeTypeFont 对象 + self.ft_font: FreeTypeFont = ImageFont.truetype(self.path, size) + # 字体字符集 + self.font_dict = self._load_cmap(self.path) + + @staticmethod + def _load_cmap(font_path: str) -> set[int]: + """返回字体支持的字形 Unicode 码位集合""" + try: + with TTFont(font_path) as tt: + return set(tt.getBestCmap().keys()) + except Exception: + return set() + + # -------------- 公有接口 -------------- + def ChoiceFont(self, char: str) -> FreeTypeFont | None: + """返回能渲染该字符的字体""" + if ord(char) in self.font_dict: + return self.ft_font + + def CheckChar(self, char: str) -> bool: + """判断主字体是否支持该字符""" + return ord(char) in self.font_dict + + def GetSize(self, text: str) -> tuple[int, int]: + """计算文本在字体下的宽高""" + if not text: + return 0, 0 + + # 优先使用缓存 + cache = self._size_cache.setdefault(self, {}) + if text in cache: + return cache[text] + + # 确定可用字体 + use_font = self.ft_font + for ch in text: + if not self.CheckChar(ch): + alt = self.ChoiceFont(ch) + if alt: + use_font = alt + break + + bbox = use_font.getbbox(text) + size = int(bbox[2] - bbox[0]), int(bbox[3] - bbox[1]) + cache[text] = size + return size + + @classmethod + def GetMixFont(cls, font_path: str | Path, font_size: int) -> "MixFont": + """工厂方法""" + font_path = str(font_path) + if not os.path.isfile(font_path): + raise FileNotFoundError(f"配置的字体未找到:{font_path}") + + # 缓存 key + key = str(hash((os.path.abspath(font_path), font_size))) + if key in cls._font_cache: + return cls._font_cache[key] + + cls._font_cache[key] = cls(font_path, font_size) + return cls._font_cache[key] + + @classmethod + def MixFontToLatexFont(cls, mix_font: "MixFont") -> pillowlatex.MixFont: + """将 MixFont 转换为 pillowlatex.MixFont""" + key = str(hash((mix_font.path, mix_font.size))) + + if key not in cls._latex_font_cache: + cls._latex_font_cache[key] = pillowlatex.MixFont( + font=mix_font.path, + size=mix_font.size, + ) + return cls._latex_font_cache[key] diff --git a/astrbot/core/utils/t2i/pillowmd/style.py b/astrbot/core/utils/t2i/pillowmd/style.py new file mode 100644 index 000000000..f2e2d1ede --- /dev/null +++ b/astrbot/core/utils/t2i/pillowmd/style.py @@ -0,0 +1,180 @@ +# pillowmd/style.py +from __future__ import annotations +from typing import Optional, Union, TypeAlias, Literal +from pathlib import Path +from dataclasses import field, dataclass +from .decorates import MDDecorates +from .mixfont import MixFont + +# ---------- 类型别名 ---------- +mdPageLineStyle: TypeAlias = Literal["full_line", "dotted_line"] +mdColor: TypeAlias = Union[tuple[int, int, int], tuple[int, int, int, int]] + + +@dataclass +class MdStyle: + """ + Markdown 生成风格 + 字段顺序、默认值、注释与 setting.yaml 保持 1:1 对应 + """ + + # 基本信息 + name: str = "Astrbot娘" # 主题名称 + intr: str = "Astrbot的默认样式" # 主题简介 + author: str = "Zhalslar" # 作者 + version: str = "1.0" # 版本号 + stylePath: str = ( + "data/t2i_styles/astrbot_style" # 主题样式文件总路径,支持相对/绝对 + ) + + # 资源目录(相对于 stylePath) + backgrounds: str = "backgrounds" # 背景图片目录 + images: str = "images" # Markdown 引用图片/缓存目录 + paintPath: str = "" # 立绘图片(autoPage=True 时生效) + + # 字体文件路径(相对于 stylePath/fonts/) + font: str = "default.ttf" # 正文字体 + titleFont: str = "fdefault.ttf" # 标题字体 + expressionFont: str = "STIXTwoMath-Regular.ttf" # 公式字体 + codeFont: str = "default.ttf" # 代码字体 + + # 字号 + fontSize: int = 25 # 正文字号 + title1FontSize: int = 70 # 一级标题 + title2FontSize: int = 55 # 二级标题 + title3FontSize: int = 40 # 三级标题 + expressionFontSizeRate: float = 0.8 # 公式字号 = 正文字号 * 该比例 + codeBlockFontSize: int = 15 # 代码块字号 + remarkFontSize: int = 14 # 备注字号 + + # 页边距 + rb: int = 200 # right distance 右边距 + lb: int = 200 # left distance 左边距 + ub: int = 200 # up distance 上边距 + db: int = 200 # down distance 下边距 + + # 画布最大宽度 + maxX: int = 1000 # 单行元素最大像素宽度 + + # 代码块 / 表格内边距 + codeLb: int = 20 # 代码块左右留白 + codeUb: int = 20 # 代码块上下留白 + formLineb: int = 20 # 表格行间距 + lineb: int = 10 # 普通行间距 + citeb: int = 30 # 引用竖线间距 + + # 页面分割线 + pageLineColor: mdColor = (253, 205, 207, 150) # RGBA + pageLineStyle: mdPageLineStyle = "dotted_line" # full_line | dotted_line + + # 列表符号颜色 + unorderedListDotColor: mdColor = (234, 149, 123) # 无序列表 + orderedListDotColor: mdColor = (241, 207, 131) # 有序列表符号 + orderedListNumberColor: mdColor = (240, 240, 233) # 有序列表数字 + + # 引用块 + citeUnderpainting: mdColor = (196, 237, 237) # 引用背景 + citeSplitLineColor: mdColor = (74, 72, 114, 200) # 引用竖线 + + # 代码块 + codeBlockUnderpainting: mdColor = (253, 205, 207, 180) # 代码块背景 + codeBlockTitleColor: mdColor = (227, 95, 130) # 代码块标题文字 + codeBlockTextColor: mdColor = (80, 89, 162) # 代码块正文 + insertCodeUnderpainting: mdColor = ( + 253, + 205, + 207, + 180, + ) # 行内代码背景 + insertCodeTextColor: mdColor = (77, 84, 139) # 行内代码文字 + + # 正文颜色 + textColor: mdColor = (98, 79, 137) + textGradientEndColor: mdColor = (186, 99, 133) # 标题渐变终止色 + linkColor: mdColor = (132, 162, 240) # 超链接 + + # 公式 + expressionUnderpainting: mdColor = (74, 72, 114) # 公式背景 + expressionTextColor: mdColor = (244, 248, 248) # 公式文字 + + # 备注 / 表单 + remarkColor: mdColor = (212, 234, 151) # 备注文字 + formTextColor: mdColor = (105, 83, 118) # 表格文字 + formLineColor: mdColor = (105, 83, 118) # 表格线 + formUnderpainting: mdColor = (212, 227, 205, 255) # 表格行背景 + formTitleUnderpainting: mdColor = (245, 213, 100, 90) # 表头背景 + + # 分割线 + idlineColor: mdColor = (186, 99, 133) # 标题下方分割线(预留) + + # 背景(已提前处理) + #background: dict + + # 装饰图 + decorates: Optional[MDDecorates] = None # 由 StyleManager 实例化后注入 + + # 其它杂项 + expressionTextSpace: int = 10 # 表达式边缘间距 + autoPage: bool = True # 是否默认自动分页 + remarkCoordinate: tuple[int, int] = (30, 2) # 标题备注坐标 (x, y) + + # 延后初始化的字体(不在 YAML 出现,由 __post_init__ 填充) + mainFont: MixFont = field(init=False, default=None) # type: ignore + fontG: MixFont = field(init=False, default=None) # type: ignore + fontC: MixFont = field(init=False, default=None) # type: ignore + font1: MixFont = field(init=False, default=None) # type: ignore + font1G: MixFont = field(init=False, default=None) # type: ignore + font2: MixFont = field(init=False, default=None) # type: ignore + font2G: MixFont = field(init=False, default=None) # type: ignore + font3: MixFont = field(init=False, default=None) # type: ignore + font3G: MixFont = field(init=False, default=None) # type: ignore + fontR: MixFont = field(init=False, default=None) # type: ignore + + + + def __post_init__(self) -> None: + # 保证资源目录存在 + sp = Path(self.stylePath).resolve() + self.paintPath = str(sp / self.paintPath) + (sp / "fonts").mkdir(parents=True, exist_ok=True) + (sp / "backgrounds").mkdir(parents=True, exist_ok=True) + (sp / "images").mkdir(parents=True, exist_ok=True) + + # 加载字体 + if self.mainFont is not None: # 已初始化 + return + base = Path(self.stylePath).resolve() / "fonts" + self.mainFont = MixFont.GetMixFont(base / self.font, self.fontSize) + self.fontG = MixFont.GetMixFont( + base / self.expressionFont, + int(self.fontSize * self.expressionFontSizeRate), + ) + self.fontC = MixFont.GetMixFont(base / self.codeFont, self.codeBlockFontSize) + self.font1 = MixFont.GetMixFont(base / self.titleFont, self.title1FontSize) + self.font1G = MixFont.GetMixFont( + base / self.expressionFont, + int(self.title1FontSize * self.expressionFontSizeRate), + ) + self.font2 = MixFont.GetMixFont(base / self.titleFont, self.title2FontSize) + self.font2G = MixFont.GetMixFont( + base / self.expressionFont, + int(self.title2FontSize * self.expressionFontSizeRate), + ) + self.font3 = MixFont.GetMixFont(base / self.titleFont, self.title3FontSize) + self.font3G = MixFont.GetMixFont( + base / self.expressionFont, + int(self.title3FontSize * self.expressionFontSizeRate), + ) + self.fontR = MixFont.GetMixFont(base / self.font, self.remarkFontSize) + + + # 工具:根据主字体返回对应公式字体 + def get_gfont(self, font: MixFont) -> MixFont: + return { + self.font1: self.font1G, + self.font2: self.font2G, + self.font3: self.font3G, + self.mainFont: self.fontG, + self.fontC: self.fontG, + self.fontR: self.fontG, + }.get(font, self.fontG) diff --git a/astrbot/core/utils/t2i/renderer.py b/astrbot/core/utils/t2i/renderer.py index 122189f93..786d6a9bb 100644 --- a/astrbot/core/utils/t2i/renderer.py +++ b/astrbot/core/utils/t2i/renderer.py @@ -12,33 +12,40 @@ def __init__(self, endpoint_url: str | None = None): async def initialize(self): await self.network_strategy.initialize() + #await self.local_strategy.initialize() async def render_custom_template( self, tmpl_str: str, tmpl_data: dict, - return_url: bool = False, options: dict | None = None, + return_url: bool = False, + use_network: bool = True, ): """使用自定义文转图模板。该方法会通过网络调用 t2i 终结点图文渲染API。 - @param tmpl_str: HTML Jinja2 模板。 - @param tmpl_data: jinja2 模板数据。 + @param tmpl_str: HTML Jinja2 模板 / Markdown 文本。 + @param tmpl_data: jinja2 模板数据 / pillowmd渲染器样式模版 @param options: 渲染选项。 @return: 图片 URL 或者文件路径,取决于 return_url 参数。 @example: 参见 https://astrbot.app 插件开发部分。 """ - return await self.network_strategy.render_custom_template( - tmpl_str, tmpl_data, return_url, options - ) + if use_network: + return await self.network_strategy.render_custom_template( + tmpl_str, tmpl_data, return_url, options + ) + else: + return await self.local_strategy.render_custom_template( + tmpl_str, tmpl_data, options + ) async def render_t2i( self, text: str, - use_network: bool = True, - return_url: bool = False, template_name: str | None = None, + return_url: bool = False, + use_network: bool = True, ): """使用默认文转图模板。""" if use_network: @@ -50,6 +57,6 @@ async def render_t2i( logger.error( f"Failed to render image via AstrBot API: {e}. Falling back to local rendering." ) - return await self.local_strategy.render(text) + return await self.local_strategy.render(text, template_name) else: - return await self.local_strategy.render(text) + return await self.local_strategy.render(text, template_name) diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/1.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/1.2.200.png new file mode 100644 index 000000000..69a6cb704 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/1.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/2.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/2.2.200.png new file mode 100644 index 000000000..afdbc84cb Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/2.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/2.3.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/2.3.200.png new file mode 100644 index 000000000..1ff1964e4 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/2.3.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/2.aa.png b/astrbot/core/utils/t2i/style/base/backgrounds/2.aa.png new file mode 100644 index 000000000..4779a2100 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/2.aa.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/3.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/3.2.200.png new file mode 100644 index 000000000..9aa81ea3e Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/3.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/4.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/4.2.200.png new file mode 100644 index 000000000..e4b694c91 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/4.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/4.3.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/4.3.200.png new file mode 100644 index 000000000..21f071a66 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/4.3.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/5.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/5.2.200.png new file mode 100644 index 000000000..64e49fb48 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/5.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/5.3.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/5.3.200.png new file mode 100644 index 000000000..d985bfe85 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/5.3.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/6.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/6.2.200.png new file mode 100644 index 000000000..7b5b50837 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/6.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/7.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/7.2.200.png new file mode 100644 index 000000000..69de3072d Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/7.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/7.3.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/7.3.200.png new file mode 100644 index 000000000..e34a8cc27 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/7.3.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/8.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/8.2.200.png new file mode 100644 index 000000000..7b45b72a8 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/8.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/backgrounds/9.2.200.png b/astrbot/core/utils/t2i/style/base/backgrounds/9.2.200.png new file mode 100644 index 000000000..5c079b630 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/backgrounds/9.2.200.png differ diff --git a/astrbot/core/utils/t2i/style/base/paint.png b/astrbot/core/utils/t2i/style/base/paint.png new file mode 100644 index 000000000..42fa107c8 Binary files /dev/null and b/astrbot/core/utils/t2i/style/base/paint.png differ diff --git a/astrbot/core/utils/t2i/style/base/setting.yaml b/astrbot/core/utils/t2i/style/base/setting.yaml new file mode 100644 index 000000000..dda252f82 --- /dev/null +++ b/astrbot/core/utils/t2i/style/base/setting.yaml @@ -0,0 +1,149 @@ +# ===================================================== +# 本地渲染器主题配置文件-示例样版 +# 基于 YAML 格式,方便写注释与后期维护 +# 所有未启用的参数均已保留,可直接取消注释后使用 +# ===================================================== + +# ---------- 基本信息 ---------- +name: "Astrbot娘" # 主题名称 +intr: "Astrbot的默认样式" # 主题简介 +author: "Zhalslar" # 作者 +version: "1.0" # 版本号 + +# stylePath: 主题样式文件总路径, 用于拼接以下的资源路径, 支持相对路径和绝对路径 +stylePath: "data/t2i_styles/astrbot_style" +# ---------- 资源路径 ---------- +# stylePath/ +# ├── backgrounds/ 背景图片目录 +# │ ├── 1.2.200.png +# │ └── xxx.png +# ├── fonts/ 字体目录 +# │ ├── default.ttf +# │ └── xxx.ttf +# ├── images/ +# │ └── xxx.png... Markdown中引用的图片所在目录、URL图片下载缓存目录 +# ├── paint.png 立绘图片 +# └── setting.yaml 样式设置 + + +# ---------- 字体文件路径 ---------- +font: "default.ttf" # 正文字体 +titleFont: "fdefault.ttf" # 标题字体 +expressionFont: "STIXTwoMath-Regular.ttf" # 公式字体 +codeFont: "default.ttf" # 代码字体 + + +# ---------- 字号 ---------- +fontSize: 25 # 正文字号 +title1FontSize: 70 # 一级标题 +title2FontSize: 55 # 二级标题 +title3FontSize: 40 # 三级标题 +expressionFontSizeRate: 0.8 # 公式字号 = 正文字号 * 该比例 +codeBlockFontSize: 15 # 代码块字号 +remarkFontSize: 14 # 备注字号 + +# ---------- 页边距 ---------- +rb: 200 # right distance 右边距 +lb: 200 # left distance 左边距 +ub: 200 # up distance 上边距 +db: 200 # down distance 下边距 + +# ---------- 画布最大宽度 ---------- +maxX: 1000 # 单行元素最大像素宽度 + +# ---------- 代码块 / 表格内边距 ---------- +codeLb: 20 # 代码块左右留白 +codeUb: 20 # 代码块上下留白 +formLineb: 20 # 表格行间距 +lineb: 10 # 普通行间距 +citeb: 30 # 引用竖线间距 + +# ---------- 页面分割线 ---------- +pageLineColor: [253, 205, 207, 150] # RGBA +pageLineStyle: "dotted_line" # full_line | dotted_line + +# ---------- 列表符号颜色 ---------- +unorderedListDotColor: [234, 149, 123] # 无序列表 +orderedListDotColor: [241, 207, 131] # 有序列表符号 +orderedListNumberColor: [240, 240, 233] # 有序列表数字 + +# ---------- 引用块 ---------- +citeUnderpainting: [196, 237, 237] # 引用背景 +citeSplitLineColor: [74, 72, 114, 200] # 引用竖线 + +# ---------- 代码块 ---------- +codeBlockUnderpainting: [253, 205, 207, 180] # 代码块背景 +codeBlockTitleColor: [227, 95, 130] # 代码块标题文字 +codeBlockTextColor: [80, 89, 162] # 代码块正文 +insertCodeUnderpainting: [253, 205, 207, 180] # 行内代码背景 +insertCodeTextColor: [77, 84, 139] # 行内代码文字 + +# ---------- 正文颜色 ---------- +textColor: [98, 79, 137] +textGradientEndColor: [186, 99, 133] # 标题渐变终止色 +linkColor: [132, 162, 240] # 超链接 + +# ---------- 公式 ---------- +expressionUnderpainting: [74, 72, 114] # 公式背景 +expressionTextColor: [244, 248, 248] # 公式文字 + +# ---------- 备注 / 表单 ---------- +remarkColor: [212, 234, 151] # 备注文字 +formTextColor: [105, 83, 118] # 表格文字 +formLineColor: [105, 83, 118] # 表格线 +formUnderpainting: [212, 227, 205, 255] # 表格行背景 +formTitleUnderpainting: [245, 213, 100, 90] # 表头背景 + +# ---------- 分割线 ---------- +idlineColor: [186, 99, 133] # 标题下方分割线(预留) + +# ---------- 背景 ---------- +background: + mode: 1 # 0=单图拉伸 1=九宫格 + data: + left-up: "1.2.200.png" + left: "4.3.200.png" + left-down: "6.2.200.png" + up: "2.3.200.png" + down: "7.3.200.png" + right-up: "3.2.200.png" + right: "5.3.200.png" + right-down: "8.2.200.png" + middle: "9.2.200.png" + lr-mode: 0 # 0=拉伸 1=横向平铺 2=居中平铺 + ud-mode: 0 # 0=拉伸 1=纵向平铺 2=居中平铺 + middle-mode: 0 # 中心区域填充模式(同背景填充枚举) + + +# ---------- 装饰图 ---------- +decorates: + top: # 顶部装饰(九宫格时可选 include 是否纳入中心区) + left-up: [] + left: [] + left-down: [] + up: [] + down: [] + right-up: [] + right: [] + right-down: [] + middle: [] + bottom: # 底部装饰 + left-up: [] + left: [] + left-down: [] + up: + - img: "2.aa.png" + mode: 0 # 0=原图 1=按区域等比缩放 + # xlimit: 0.5 # 以下参数仅在 mode=1 时生效 + # ylimit: 0.2 + # min: 0.5 + # max: 2.0 + # include: true # 是否只在中心区域绘制 + # lock: false # 是否锁定不跟随 GIF 换帧 + down: [] + right-up: [] + right: [] + right-down: [] + middle: [] + +autoPage: true # 是否默认自动分页,仅默认值,实际调用渲染器时可传参指定是否分页 \ No newline at end of file diff --git a/astrbot/core/utils/t2i/style_manager.py b/astrbot/core/utils/t2i/style_manager.py new file mode 100644 index 000000000..2e7acc4f3 --- /dev/null +++ b/astrbot/core/utils/t2i/style_manager.py @@ -0,0 +1,142 @@ +# astrbot/core/utils/t2i/style_manager.py + +import os +import shutil +import yaml +from typing import Any +from pathlib import Path +from astrbot.core.utils.t2i.pillowmd.decorates import MDDecorates +from astrbot.core.utils.t2i.pillowmd.style import MdStyle +from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path + + +class StyleManeger: + COLOR_KEYS = [ + "pageLineColor", + "unorderedListDotColor", + "orderedListDotColor", + "orderedListNumberColor", + "citeUnderpainting", + "citeSplitLineColor", + "codeBlockUnderpainting", + "codeBlockTitleColor", + "formLineColor", + "textColor", + "textGradientEndColor", + "linkColor", + "expressionUnderpainting", + "insertCodeUnderpainting", + "idlineColor", + "expressionTextColor", + "insertCodeTextColor", + "codeBlockTextColor", + "remarkColor", + "formTextColor", + "formUnderpainting", + "formTitleUnderpainting", + ] + STYLE_CACHE: dict[str, MdStyle] = {} + CORE_STYLES = ["setting.yaml", "paint.png", "backgrounds"] + DEFAULT_STYLE_NAME = "base" + + def __init__(self): + self.builtin_style_dir = os.path.join( + get_astrbot_path(), "astrbot", "core", "utils", "t2i", "style" + ) + self.user_style_dir = os.path.join( + get_astrbot_data_path(), "t2i_styles" + ) + + os.makedirs(self.user_style_dir, exist_ok=True) + + # 如果用户目录下缺少核心模板,则进行复制 + self._copy_core_styles(overwrite=False) + + + def _copy_core_styles(self, overwrite: bool = False): + """把内置整套主题(含子目录)一次性镜像到用户目录""" + for name in self.CORE_STYLES: + src = Path(self.builtin_style_dir) / name + dst = Path(self.user_style_dir) / name + if not src.exists(): + continue + if src.is_dir(): + # 整目录递归复制 + if overwrite: + shutil.rmtree(dst, ignore_errors=True) + shutil.copytree(src, dst, dirs_exist_ok=True) + else: + # 单文件 + dst.parent.mkdir(parents=True, exist_ok=True) + if overwrite or not dst.exists(): + shutil.copy2(src, dst) + + + def _yaml_load(self, path: str): + if os.path.exists(path): + with open(path, encoding="utf-8") as fp: + return yaml.safe_load(fp) + raise FileNotFoundError(path) + + def create_style(self, data: dict): + """ + 根据给定目录加载 Markdown 风格配置并返回 MdStyle 实例。 + """ + style_path = Path(data["stylePath"]).resolve() + # 1. 颜色字段自动转 tuple + items: dict[str, Any] = { + k: tuple(v) if k in self.COLOR_KEYS else v for k, v in data.items() + } + + # 2. 取出 background & decorates,用于构建 MDDecorates + background = items.pop("background") + decorates_cfg = items.pop("decorates") + + # 3. 构建装饰器 + items["decorates"] = MDDecorates( + backGroundMode=background["mode"], # type: ignore + backGroundData=background["data"], # type: ignore + topDecorates=decorates_cfg["top"], # type: ignore + bottomDecorates=decorates_cfg["bottom"], # type: ignore + backGroundsPath=style_path / "backgrounds", + ) + + # 4. 最终构造 MdStyle + md_style = MdStyle(**items) + self.STYLE_CACHE[style_path.name] = md_style + return md_style + + + + + def get_style_from_name(self, name: str | None) -> MdStyle: + """ + 根据给定目录获取 Markdown 风格配置。 + + 说明: + - 优先读取缓存。 + - 若缓存中不存在,则尝试从用户目录加载。 + - 若用户目录中不存在,则尝试从内置目录加载。 + """ + name = name or self.DEFAULT_STYLE_NAME + if name in self.STYLE_CACHE: + return self.STYLE_CACHE[name] + + user_file = os.path.join(self.user_style_dir, name, "setting.yaml") + if os.path.exists(user_file): + data = self._yaml_load(user_file) + return self.create_style(data) + + builtin_file = os.path.join(self.builtin_style_dir, name, "setting.yaml") + if os.path.exists(builtin_file): + data = self._yaml_load(builtin_file) + return self.create_style(data) + print(user_file, builtin_file) + raise FileNotFoundError(f"样式【{name}】不存在") + + + def get_style_from_dict(self, setting: dict) -> MdStyle: + """ + 加载 Markdown 风格配置, 传入色 setting 需符合渲染器要求的格式 + """ + return self.create_style(setting)