From e5de30c95c21c9c4798d2c8b685de7814f815f72 Mon Sep 17 00:00:00 2001 From: fllesser Date: Thu, 28 May 2026 12:06:04 +0800 Subject: [PATCH 1/4] perf(renderer): enhance CommonRenderer and resource management - Introduced CommonRenderer for unified rendering of content. - Added asset management functions to load resources such as fonts and images. - Created typography and asset modules to handle font metrics and resource loading. - Updated tests to ensure proper functionality of the new rendering system. --- src/nonebot_plugin_parser/renders/__init__.py | 7 +- .../renders/common/__init__.py | 4 + .../renders/common/assets.py | 94 ++++++++ .../renders/{common.py => common/renderer.py} | 224 +++++------------- .../renders/common/typography.py | 86 +++++++ tests/renders/test_font.py | 20 +- tests/renders/test_platform.py | 7 +- 7 files changed, 265 insertions(+), 177 deletions(-) create mode 100644 src/nonebot_plugin_parser/renders/common/__init__.py create mode 100644 src/nonebot_plugin_parser/renders/common/assets.py rename src/nonebot_plugin_parser/renders/{common.py => common/renderer.py} (76%) create mode 100644 src/nonebot_plugin_parser/renders/common/typography.py diff --git a/src/nonebot_plugin_parser/renders/__init__.py b/src/nonebot_plugin_parser/renders/__init__.py index 74bd7733..f5b7bdb2 100644 --- a/src/nonebot_plugin_parser/renders/__init__.py +++ b/src/nonebot_plugin_parser/renders/__init__.py @@ -1,6 +1,6 @@ import importlib -from nonebot import logger, get_driver +from nonebot import logger from .base import BaseRenderer from .common import CommonRenderer @@ -37,8 +37,3 @@ def get_renderer(platform: str) -> type[BaseRenderer]: module = importlib.import_module("." + platform, package=__name__) return getattr(module, "Renderer") - - -@get_driver().on_startup -async def load_resources(): - CommonRenderer.load_resources() diff --git a/src/nonebot_plugin_parser/renders/common/__init__.py b/src/nonebot_plugin_parser/renders/common/__init__.py new file mode 100644 index 00000000..2607f536 --- /dev/null +++ b/src/nonebot_plugin_parser/renders/common/__init__.py @@ -0,0 +1,4 @@ +from .assets import load_resources, ensure_resources +from .renderer import CommonRenderer + +__all__ = ["CommonRenderer", "ensure_resources", "load_resources"] diff --git a/src/nonebot_plugin_parser/renders/common/assets.py b/src/nonebot_plugin_parser/renders/common/assets.py new file mode 100644 index 00000000..b17646df --- /dev/null +++ b/src/nonebot_plugin_parser/renders/common/assets.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from PIL import Image +from nonebot import logger, get_driver + +from .. import resources +from ...config import pconfig +from .typography import CardFonts, CardTheme + +PILImage = Image.Image + +DEFAULT_THEME = CardTheme( + name=(0, 122, 255), + title=(102, 51, 153), + body=(51, 51, 51), + muted=(136, 136, 136), +) + +AVATAR_SIZE = 80 +FONTS: CardFonts +PLATFORM_LOGOS: dict[str, PILImage] +AVATAR_IMAGE: PILImage +VIDEO_BUTTON_IMAGE: PILImage + +_resources_loaded = False + + +@get_driver().on_startup +async def load_common_renderer_resources(): + load_resources() + + +def ensure_resources() -> None: + if _resources_loaded: + return + load_resources() + + +def load_resources() -> None: + """加载渲染资源(幂等)""" + global _resources_loaded, FONTS, PLATFORM_LOGOS, AVATAR_IMAGE, VIDEO_BUTTON_IMAGE + + if _resources_loaded: + return + + FONTS = _load_fonts() + PLATFORM_LOGOS = _load_platform_logos() + AVATAR_IMAGE = _load_default_avatar() + VIDEO_BUTTON_IMAGE = _load_video_button() + + _resources_loaded = True + + +def _load_fonts() -> CardFonts: + """字体(昵称 / 标题 / 正文 / 辅助文案)""" + font_path = pconfig.custom_font or resources.DEFAULT_FONT_PATH + loaded = CardFonts.load(font_path, DEFAULT_THEME) + logger.success(f"加载字体「{font_path.name}」成功") + return loaded + + +def _load_platform_logos() -> dict[str, PILImage]: + """平台 Logo""" + from ...constants import PlatformEnum + + logos: dict[str, PILImage] = {} + loaded_platforms = [] + for platform_name in PlatformEnum: + logo_path = resources.RESOURCES_DIR / f"{platform_name}.png" + if logo_path.exists(): + with Image.open(logo_path) as img: + logos[str(platform_name)] = img.convert("RGBA") + loaded_platforms.append(platform_name) + logger.debug(f"加载 Logo「{', '.join(loaded_platforms)}」成功") + return logos + + +def _load_default_avatar() -> PILImage: + """默认头像(作者无头像或加载失败时回退)""" + with Image.open(resources.DEFAULT_AVATAR_PATH) as img: + loaded = img.convert("RGBA").resize((AVATAR_SIZE, AVATAR_SIZE)) + logger.debug(f"加载头像「{resources.DEFAULT_AVATAR_PATH.name}」成功") + return loaded + + +def _load_video_button() -> PILImage: + """视频播放按钮(封面居中叠加,半透明)""" + with Image.open(resources.DEFAULT_VIDEO_BUTTON_PATH) as img: + button = img.convert("RGBA").resize((100, 100)) + alpha = button.split()[-1] + alpha = alpha.point(lambda x: int(x * 0.6)) + button.putalpha(alpha) + logger.debug(f"加载视频播放按钮「{resources.DEFAULT_VIDEO_BUTTON_PATH.name}」成功") + return button diff --git a/src/nonebot_plugin_parser/renders/common.py b/src/nonebot_plugin_parser/renders/common/renderer.py similarity index 76% rename from src/nonebot_plugin_parser/renders/common.py rename to src/nonebot_plugin_parser/renders/common/renderer.py index 21843a8b..27d88407 100644 --- a/src/nonebot_plugin_parser/renders/common.py +++ b/src/nonebot_plugin_parser/renders/common/renderer.py @@ -1,19 +1,19 @@ from io import BytesIO from typing import ClassVar from pathlib import Path -from functools import lru_cache -from dataclasses import dataclass from typing_extensions import override import emoji from PIL import Image, ImageDraw, ImageFont from nonebot import logger from apilmoji import Apilmoji, EmojiCDNSource -from apilmoji.core import get_font_height -from . import resources -from .base import ParseResult, ImageContent, ImageRenderer -from ..config import pconfig +from . import assets +from .. import resources +from ..base import ParseResult, ImageContent, ImageRenderer +from .assets import AVATAR_SIZE as _AVATAR_SIZE +from ...config import pconfig +from .typography import StyledFont, FontMetrics Color = tuple[int, int, int] PILImage = Image.Image @@ -27,64 +27,13 @@ logger.error("未安装 cairo, 无法使用 emosvg 渲染 emoji") emosvg = None - -@dataclass(eq=False, frozen=True, slots=True) -class FontInfo: - """字体信息数据类""" - - font: ImageFont.FreeTypeFont - fill: Color - line_height: int - cjk_width: int - - def __hash__(self) -> int: - return hash((id(self.font), self.line_height, self.cjk_width, self.fill)) - - @lru_cache(maxsize=500) - def get_char_width(self, char: str) -> int: - bbox = self.font.getbbox(char) - return int(bbox[2] - bbox[0]) - - def get_char_width_fast(self, char: str) -> int: - if "\u4e00" <= char <= "\u9fff": - return self.cjk_width - return self.get_char_width(char) - - def get_text_width(self, text: str) -> int: - if not text: - return 0 - return sum(self.get_char_width_fast(char) for char in text) - - -@dataclass(eq=False, frozen=True, slots=True) -class FontSet: - """字体集数据类""" - - _FONT_INFOS = ( - ("name", 28, (0, 122, 255)), - ("title", 30, (102, 51, 153)), - ("text", 24, (51, 51, 51)), - ("extra", 24, (136, 136, 136)), - ) - - name: FontInfo - title: FontInfo - text: FontInfo - extra: FontInfo - - @classmethod - def new(cls, font_path: Path): - font_infos: dict[str, FontInfo] = {} - for name, size, fill in cls._FONT_INFOS: - font = ImageFont.truetype(font_path, size) - height = get_font_height(font) - font_infos[name] = FontInfo( - font=font, - fill=fill, - line_height=height, - cjk_width=size, - ) - return FontSet(**font_infos) +# apilmoji emoji 源 +EMOJI_SOURCE = EmojiCDNSource( + base_url=pconfig.emoji_cdn, + style=pconfig.emoji_style, + cache_dir=pconfig.cache_dir / "emojis", + show_progress=True, +) class CommonRenderer(ImageRenderer): @@ -92,7 +41,7 @@ class CommonRenderer(ImageRenderer): # 布局常量 PADDING = 25 - AVATAR_SIZE = 80 + AVATAR_SIZE = _AVATAR_SIZE AVATAR_TEXT_GAP = 15 SECTION_SPACING = 15 NAME_TIME_GAP = 5 @@ -116,16 +65,9 @@ class CommonRenderer(ImageRenderer): REPOST_BG_COLOR: ClassVar[Color] = (247, 247, 247) REPOST_BORDER_COLOR: ClassVar[Color] = (230, 230, 230) - # apilmoji emoji 源 - EMOJI_SOURCE: ClassVar[EmojiCDNSource] = EmojiCDNSource( - base_url=pconfig.emoji_cdn, - style=pconfig.emoji_style, - cache_dir=pconfig.cache_dir / "emojis", - show_progress=True, - ) - def __init__(self, result: ParseResult, not_repost: bool = True): super().__init__(result, not_repost) + assets.ensure_resources() self.card_width: int = self.DEFAULT_CARD_WIDTH self.content_width: int = self.card_width - 2 * self.PADDING @@ -140,49 +82,6 @@ def __init__(self, result: ParseResult, not_repost: bool = True): False, ) - @classmethod - def load_resources(cls): - """加载资源""" - cls._load_fonts() - cls._load_platform_logos() - cls._load_other_resources() - - @classmethod - def _load_fonts(cls): - font_path = pconfig.custom_font or resources.DEFAULT_FONT_PATH - cls.fontset = FontSet.new(font_path) - logger.success(f"加载字体「{font_path.name}」成功") - - @classmethod - def _load_platform_logos(cls): - from ..constants import PlatformEnum - - cls.platform_logos: dict[str, PILImage] = {} - loaded_platforms = [] - for platform_name in PlatformEnum: - logo_path = resources.RESOURCES_DIR / f"{platform_name}.png" - - if logo_path.exists(): - with Image.open(logo_path) as img: - cls.platform_logos[str(platform_name)] = img.convert("RGBA") - loaded_platforms.append(platform_name) - logger.debug(f"加载 Logo「{', '.join(loaded_platforms)}」成功") - - @classmethod - def _load_other_resources(cls): - # avatar - with Image.open(resources.DEFAULT_AVATAR_PATH) as img: - cls.avatar_image: PILImage = img.convert("RGBA").resize((cls.AVATAR_SIZE, cls.AVATAR_SIZE)) - logger.debug(f"加载头像「{resources.DEFAULT_AVATAR_PATH.name}」成功") - - # video button - with Image.open(resources.DEFAULT_VIDEO_BUTTON_PATH) as img: - cls.video_button_image: PILImage = img.convert("RGBA").resize((100, 100)) - alpha = cls.video_button_image.split()[-1] - alpha = alpha.point(lambda x: int(x * 0.6)) - cls.video_button_image.putalpha(alpha) - logger.debug(f"加载视频播放按钮「{resources.DEFAULT_VIDEO_BUTTON_PATH.name}」成功") - @override async def render_image(self) -> bytes: image = await self._render_image() @@ -214,11 +113,11 @@ async def _render_image(self) -> PILImage: def _estimate_text_height( self, text: str, - font: FontInfo, + metrics: FontMetrics, content_width: int, ) -> int: """估算文本高度(考虑换行符)""" - return (text.count("\n") + 1 + len(text) * font.cjk_width // content_width) * font.line_height + return (text.count("\n") + 1 + len(text) * metrics.cjk_width // content_width) * metrics.line_height def _estimate_height(self) -> int: """估算画布高度""" @@ -233,7 +132,7 @@ def _estimate_height(self) -> int: if self.result.title: height += self._estimate_text_height( self.result.title, - self.fontset.title, + assets.FONTS.title.metrics, self.content_width, ) height += self.SECTION_SPACING @@ -244,7 +143,7 @@ def _estimate_height(self) -> int: if isinstance(item, str): height += self._estimate_text_height( item, - self.fontset.text, + assets.FONTS.body.metrics, self.content_width, ) else: @@ -258,14 +157,19 @@ def _estimate_height(self) -> int: if self.result.text: height += self._estimate_text_height( self.result.text, - self.fontset.text, + assets.FONTS.body.metrics, self.content_width, ) height += self.SECTION_SPACING # 额外信息 if self.result.extra_info: - height += self.fontset.extra.line_height * 3 + self.SECTION_SPACING + height += self._estimate_text_height( + self.result.extra_info, + assets.FONTS.muted.metrics, + self.content_width, + ) + height += self.SECTION_SPACING # 转发内容 if self.result.repost: @@ -289,9 +193,9 @@ async def _render_header(self) -> None: # 文字区域 text_x = self.PADDING + self.AVATAR_SIZE + self.AVATAR_TEXT_GAP - name_height = self.fontset.name.line_height + name_height = assets.FONTS.name.metrics.line_height time_str = self.result.formartted_datetime - time_height = (self.NAME_TIME_GAP + self.fontset.extra.line_height) if time_str else 0 + time_height = (self.NAME_TIME_GAP + assets.FONTS.muted.metrics.line_height) if time_str else 0 text_height = name_height + time_height # 垂直居中 @@ -301,8 +205,8 @@ async def _render_header(self) -> None: self._draw.text( (text_x, text_y), self.result.author.name, - font=self.fontset.name.font, - fill=self.fontset.name.fill, + font=assets.FONTS.name.metrics.font, + fill=assets.FONTS.name.fill, ) text_y += name_height @@ -312,15 +216,15 @@ async def _render_header(self) -> None: self._draw.text( (text_x, text_y), time_str, - font=self.fontset.extra.font, - fill=self.fontset.extra.fill, + font=assets.FONTS.muted.metrics.font, + fill=assets.FONTS.muted.fill, ) # 平台 Logo if self.not_repost: platform_name = self.result.platform.name - if platform_name in self.platform_logos: - logo = self.platform_logos[platform_name] + if platform_name in assets.PLATFORM_LOGOS: + logo = assets.PLATFORM_LOGOS[platform_name] logo_x = self._image.width - self.PADDING - logo.width logo_y = self.y_pos + (self.AVATAR_SIZE - logo.height) // 2 self._image.paste(logo, (logo_x, logo_y), logo) @@ -330,7 +234,7 @@ async def _render_header(self) -> None: def _load_avatar(self, avatar_path: Path | None) -> PILImage: """加载头像(带圆形裁剪)""" if avatar_path is None or not avatar_path.exists(): - return self.avatar_image + return assets.AVATAR_IMAGE try: with Image.open(avatar_path) as img: @@ -340,7 +244,7 @@ def _load_avatar(self, avatar_path: Path | None) -> PILImage: Image.Resampling.LANCZOS, ) except Exception: - return self.avatar_image + return assets.AVATAR_IMAGE # 圆形遮罩 mask = Image.new("L", (self.AVATAR_SIZE, self.AVATAR_SIZE), 0) @@ -356,9 +260,9 @@ async def _render_title(self) -> None: lines = self._wrap_text( self.result.title, self.content_width, - self.fontset.title, + assets.FONTS.title.metrics, ) - self.y_pos += await self._draw_text(lines, self.fontset.title) + self.y_pos += await self._draw_text(lines, assets.FONTS.title) self.y_pos += self.SECTION_SPACING async def _render_main_content(self) -> None: @@ -416,15 +320,15 @@ async def _load_cover(self) -> PILImage | None: btn_size = 100 btn_x, btn_y = (img.width - btn_size) // 2, (img.height - btn_size) // 2 img.paste( - self.video_button_image, + assets.VIDEO_BUTTON_IMAGE, (btn_x, btn_y), - self.video_button_image, + assets.VIDEO_BUTTON_IMAGE, ) # 视频时长 # display_duration = video_content.display_duration - # font = self.fontset.extra + # paint = assets.FONTS.muted # text_width = font.get_text_width(display_duration) # # 计算文本绘制位置 # text_x = img.width - text_width - 20 @@ -450,8 +354,8 @@ async def _load_cover(self) -> PILImage | None: # ImageDraw.Draw(img).text( # (text_x, text_y), # display_duration, - # font=self.fontset.extra.font, - # fill=self.fontset.extra.fill, + # font=paint.metrics.font, + # fill=paint.fill, # ) return img.copy() @@ -594,15 +498,16 @@ async def _render_img_in_graphics(self, image_content: ImageContent) -> None: # Alt 文本 if image_content.alt: self.y_pos += self.SECTION_SPACING - text_w = self.fontset.extra.get_text_width(image_content.alt) + paint = assets.FONTS.muted + text_w = paint.metrics.get_text_width(image_content.alt) text_x = self.PADDING + (self.content_width - text_w) // 2 self._draw.text( (text_x, self.y_pos), image_content.alt, - font=self.fontset.extra.font, - fill=self.fontset.extra.fill, + font=paint.metrics.font, + fill=paint.fill, ) - self.y_pos += self.fontset.extra.line_height + self.y_pos += paint.metrics.line_height self.y_pos += self.SECTION_SPACING @@ -615,9 +520,9 @@ async def _render_text(self, text: str | None = None) -> None: lines = self._wrap_text( text, self.content_width, - self.fontset.text, + assets.FONTS.body.metrics, ) - self.y_pos += await self._draw_text(lines, self.fontset.text) + self.y_pos += await self._draw_text(lines, assets.FONTS.body) self.y_pos += self.SECTION_SPACING async def _render_extra(self) -> None: @@ -628,9 +533,9 @@ async def _render_extra(self) -> None: lines = self._wrap_text( self.result.extra_info, self.content_width, - self.fontset.extra, + assets.FONTS.muted.metrics, ) - self.y_pos += await self._draw_text(lines, self.fontset.extra) + self.y_pos += await self._draw_text(lines, assets.FONTS.muted) async def _render_repost(self) -> None: """渲染转发内容""" @@ -664,34 +569,35 @@ async def _render_repost(self) -> None: self._image.paste(repost_img, (card_x, card_y)) self.y_pos += container_h + self.SECTION_SPACING - async def _draw_text(self, lines: list[str], font: FontInfo) -> int: + async def _draw_text(self, lines: list[str], styled: StyledFont) -> int: """绘制多行文本""" if not lines: return 0 + metrics = styled.metrics xy = (self.PADDING, self.y_pos) if emosvg is not None: emosvg.text( self._image, xy, lines, - font.font, - fill=font.fill, - line_height=font.line_height, + metrics.font, + fill=styled.fill, + line_height=metrics.line_height, ) else: await Apilmoji.text( self._image, xy, lines, - font.font, - fill=font.fill, - line_height=font.line_height, - source=self.EMOJI_SOURCE, + metrics.font, + fill=styled.fill, + line_height=metrics.line_height, + source=EMOJI_SOURCE, ) - return font.line_height * len(lines) + return metrics.line_height * len(lines) - def _wrap_text(self, text: str, max_width: int, font: FontInfo) -> list[str]: + def _wrap_text(self, text: str, max_width: int, metrics: FontMetrics) -> list[str]: """文本自动换行""" if not text: return [] @@ -718,12 +624,12 @@ def _wrap_text(self, text: str, max_width: int, font: FontInfo) -> list[str]: if ed["match_start"] == idx: char = ed["emoji"] idx = ed["match_end"] - char_width = font.font.size + char_width = metrics.font.size break else: char = paragraph[idx] idx += 1 - char_width = font.get_char_width_fast(char) + char_width = metrics.get_char_width_fast(char) if not current_line: current_line = char diff --git a/src/nonebot_plugin_parser/renders/common/typography.py b/src/nonebot_plugin_parser/renders/common/typography.py new file mode 100644 index 00000000..3c4d7481 --- /dev/null +++ b/src/nonebot_plugin_parser/renders/common/typography.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from pathlib import Path +from functools import lru_cache +from dataclasses import dataclass + +from PIL import ImageFont +from apilmoji.core import get_font_height + +Color = tuple[int, int, int] + + +@dataclass(eq=False, frozen=True, slots=True) +class FontMetrics: + """字体度量(换行、估高)""" + + font: ImageFont.FreeTypeFont + line_height: int + cjk_width: int + + def __hash__(self) -> int: + return hash((id(self.font), self.line_height, self.cjk_width)) + + @lru_cache(maxsize=500) + def get_char_width(self, char: str) -> int: + bbox = self.font.getbbox(char) + return int(bbox[2] - bbox[0]) + + def get_char_width_fast(self, char: str) -> int: + if "\u4e00" <= char <= "\u9fff": + return self.cjk_width + return self.get_char_width(char) + + def get_text_width(self, text: str) -> int: + if not text: + return 0 + return sum(self.get_char_width_fast(char) for char in text) + + +@dataclass(frozen=True, slots=True) +class CardTheme: + """卡片颜色""" + + name: Color + title: Color + body: Color + muted: Color + + +@dataclass(frozen=True, slots=True) +class StyledFont: + """度量 + 颜色""" + + metrics: FontMetrics + fill: Color + + +def _load_styled(font_path: Path, size: int, fill: Color) -> StyledFont: + font = ImageFont.truetype(font_path, size) + return StyledFont( + metrics=FontMetrics( + font=font, + line_height=get_font_height(font), + cjk_width=size, + ), + fill=fill, + ) + + +@dataclass(frozen=True, slots=True) +class CardFonts: + """卡片各区块字体(加载时组合 theme)""" + + name: StyledFont + title: StyledFont + body: StyledFont + muted: StyledFont + + @classmethod + def load(cls, font_path: Path, theme: CardTheme) -> CardFonts: + return cls( + name=_load_styled(font_path, 28, theme.name), + title=_load_styled(font_path, 30, theme.title), + body=_load_styled(font_path, 24, theme.body), + muted=_load_styled(font_path, 24, theme.muted), + ) diff --git a/tests/renders/test_font.py b/tests/renders/test_font.py index 00117b8c..7dec1a85 100644 --- a/tests/renders/test_font.py +++ b/tests/renders/test_font.py @@ -2,27 +2,29 @@ def test_font(): - from nonebot_plugin_parser.renders import CommonRenderer + from nonebot_plugin_parser.renders.common import assets - font = CommonRenderer.fontset.text + assets.ensure_resources() + metrics = assets.FONTS.body.metrics chars = ["中", "A", "1", "a", ",", "。"] for char in chars: - logger.info(f"{char}: {font.get_char_width(char)}") + logger.info(f"{char}: {metrics.get_char_width(char)}") for char in range(128): char = chr(char) - logger.info(f"{char}: {font.get_char_width(char)}") + logger.info(f"{char}: {metrics.get_char_width(char)}") def test_cjk_width(): - from nonebot_plugin_parser.renders import CommonRenderer + from nonebot_plugin_parser.renders.common import assets - font = CommonRenderer.fontset.name + assets.ensure_resources() + metrics = assets.FONTS.name.metrics count = 0 for char_ord in range(ord("\u4e00"), ord("\u9fff")): char = chr(char_ord) - width = font.get_char_width(char) - if width != font.cjk_width: - # logger.warning(f"{char}({char_ord}): {width} != {font.cjk_width}") + width = metrics.get_char_width(char) + if width != metrics.cjk_width: + # logger.warning(f"{char}({char_ord}): {width} != {metrics.cjk_width}") count += 1 cjk_count = ord("\u9fff") - ord("\u4e00") + 1 logger.info(f"CJK 字符数: {cjk_count},不等于 CJK 宽度的字符数: {count},占比: {count / cjk_count:.2%}") diff --git a/tests/renders/test_platform.py b/tests/renders/test_platform.py index 2e691196..53f440c7 100644 --- a/tests/renders/test_platform.py +++ b/tests/renders/test_platform.py @@ -1,8 +1,9 @@ def test_platform_enum(): - from nonebot_plugin_parser.renders import CommonRenderer from nonebot_plugin_parser.constants import PlatformEnum + from nonebot_plugin_parser.renders.common import assets + assets.ensure_resources() assert PlatformEnum.BILIBILI == "bilibili" assert str(PlatformEnum.BILIBILI) == "bilibili" - assert CommonRenderer.platform_logos[PlatformEnum.BILIBILI] is not None - assert CommonRenderer.platform_logos[str(PlatformEnum.BILIBILI)] is not None + assert assets.PLATFORM_LOGOS[PlatformEnum.BILIBILI] is not None + assert assets.PLATFORM_LOGOS[str(PlatformEnum.BILIBILI)] is not None From 550d89f7231c8abaf98e866d148d74af14f2e5b9 Mon Sep 17 00:00:00 2001 From: fllesser Date: Thu, 28 May 2026 12:11:38 +0800 Subject: [PATCH 2/4] refactor(assets): integrate EmojiCDNSource into assets module - Moved EMOJI_SOURCE initialization to the assets module for better resource management. - Updated CommonRenderer to utilize the new EMOJI_SOURCE from assets, improving code organization and maintainability. --- src/nonebot_plugin_parser/renders/common/assets.py | 9 +++++++++ .../renders/common/renderer.py | 13 ++----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/nonebot_plugin_parser/renders/common/assets.py b/src/nonebot_plugin_parser/renders/common/assets.py index b17646df..b998532d 100644 --- a/src/nonebot_plugin_parser/renders/common/assets.py +++ b/src/nonebot_plugin_parser/renders/common/assets.py @@ -2,6 +2,7 @@ from PIL import Image from nonebot import logger, get_driver +from apilmoji import EmojiCDNSource from .. import resources from ...config import pconfig @@ -9,6 +10,14 @@ PILImage = Image.Image +# apilmoji emoji 源 +EMOJI_SOURCE = EmojiCDNSource( + base_url=pconfig.emoji_cdn, + style=pconfig.emoji_style, + cache_dir=pconfig.cache_dir / "emojis", + show_progress=True, +) + DEFAULT_THEME = CardTheme( name=(0, 122, 255), title=(102, 51, 153), diff --git a/src/nonebot_plugin_parser/renders/common/renderer.py b/src/nonebot_plugin_parser/renders/common/renderer.py index 27d88407..27e78e7f 100644 --- a/src/nonebot_plugin_parser/renders/common/renderer.py +++ b/src/nonebot_plugin_parser/renders/common/renderer.py @@ -6,13 +6,12 @@ import emoji from PIL import Image, ImageDraw, ImageFont from nonebot import logger -from apilmoji import Apilmoji, EmojiCDNSource +from apilmoji import Apilmoji from . import assets from .. import resources from ..base import ParseResult, ImageContent, ImageRenderer from .assets import AVATAR_SIZE as _AVATAR_SIZE -from ...config import pconfig from .typography import StyledFont, FontMetrics Color = tuple[int, int, int] @@ -27,14 +26,6 @@ logger.error("未安装 cairo, 无法使用 emosvg 渲染 emoji") emosvg = None -# apilmoji emoji 源 -EMOJI_SOURCE = EmojiCDNSource( - base_url=pconfig.emoji_cdn, - style=pconfig.emoji_style, - cache_dir=pconfig.cache_dir / "emojis", - show_progress=True, -) - class CommonRenderer(ImageRenderer): """统一渲染器""" @@ -593,7 +584,7 @@ async def _draw_text(self, lines: list[str], styled: StyledFont) -> int: metrics.font, fill=styled.fill, line_height=metrics.line_height, - source=EMOJI_SOURCE, + source=assets.EMOJI_SOURCE, ) return metrics.line_height * len(lines) From e417d2b1d14225c21a718a254e1a4de818980ffc Mon Sep 17 00:00:00 2001 From: fllesser Date: Thu, 28 May 2026 13:32:26 +0800 Subject: [PATCH 3/4] feat(font): add font metrics and styling for card rendering - Introduced new font module to handle font metrics and styles for card components. - Updated CommonRenderer to utilize the new font metrics for improved text rendering. - Refactored asset imports to streamline resource management and enhance code organization. --- .../renders/common/assets.py | 2 +- .../renders/common/{typography.py => font.py} | 2 +- .../renders/common/renderer.py | 92 ++++++++++--------- 3 files changed, 51 insertions(+), 45 deletions(-) rename src/nonebot_plugin_parser/renders/common/{typography.py => font.py} (97%) diff --git a/src/nonebot_plugin_parser/renders/common/assets.py b/src/nonebot_plugin_parser/renders/common/assets.py index b998532d..913a8350 100644 --- a/src/nonebot_plugin_parser/renders/common/assets.py +++ b/src/nonebot_plugin_parser/renders/common/assets.py @@ -5,8 +5,8 @@ from apilmoji import EmojiCDNSource from .. import resources +from .font import CardFonts, CardTheme from ...config import pconfig -from .typography import CardFonts, CardTheme PILImage = Image.Image diff --git a/src/nonebot_plugin_parser/renders/common/typography.py b/src/nonebot_plugin_parser/renders/common/font.py similarity index 97% rename from src/nonebot_plugin_parser/renders/common/typography.py rename to src/nonebot_plugin_parser/renders/common/font.py index 3c4d7481..38dd52e0 100644 --- a/src/nonebot_plugin_parser/renders/common/typography.py +++ b/src/nonebot_plugin_parser/renders/common/font.py @@ -69,7 +69,7 @@ def _load_styled(font_path: Path, size: int, fill: Color) -> StyledFont: @dataclass(frozen=True, slots=True) class CardFonts: - """卡片各区块字体(加载时组合 theme)""" + """卡片各区块字体(加载时组合 theme)""" name: StyledFont title: StyledFont diff --git a/src/nonebot_plugin_parser/renders/common/renderer.py b/src/nonebot_plugin_parser/renders/common/renderer.py index 27e78e7f..2d0609b6 100644 --- a/src/nonebot_plugin_parser/renders/common/renderer.py +++ b/src/nonebot_plugin_parser/renders/common/renderer.py @@ -8,11 +8,18 @@ from nonebot import logger from apilmoji import Apilmoji -from . import assets -from .. import resources +from .font import StyledFont, FontMetrics from ..base import ParseResult, ImageContent, ImageRenderer -from .assets import AVATAR_SIZE as _AVATAR_SIZE -from .typography import StyledFont, FontMetrics +from .assets import ( + FONTS, + AVATAR_SIZE, + AVATAR_IMAGE, + EMOJI_SOURCE, + PLATFORM_LOGOS, + VIDEO_BUTTON_IMAGE, + ensure_resources, +) +from ..resources import DEFAULT_FONT_PATH, random_fallback_pic Color = tuple[int, int, int] PILImage = Image.Image @@ -32,7 +39,6 @@ class CommonRenderer(ImageRenderer): # 布局常量 PADDING = 25 - AVATAR_SIZE = _AVATAR_SIZE AVATAR_TEXT_GAP = 15 SECTION_SPACING = 15 NAME_TIME_GAP = 5 @@ -58,7 +64,7 @@ class CommonRenderer(ImageRenderer): def __init__(self, result: ParseResult, not_repost: bool = True): super().__init__(result, not_repost) - assets.ensure_resources() + ensure_resources() self.card_width: int = self.DEFAULT_CARD_WIDTH self.content_width: int = self.card_width - 2 * self.PADDING @@ -117,13 +123,13 @@ def _estimate_height(self) -> int: # 头部(头像 + 名称 + 时间) if self.result.author: - height += self.AVATAR_SIZE + self.SECTION_SPACING + height += AVATAR_SIZE + self.SECTION_SPACING # 标题 if self.result.title: height += self._estimate_text_height( self.result.title, - assets.FONTS.title.metrics, + FONTS.title.metrics, self.content_width, ) height += self.SECTION_SPACING @@ -134,7 +140,7 @@ def _estimate_height(self) -> int: if isinstance(item, str): height += self._estimate_text_height( item, - assets.FONTS.body.metrics, + FONTS.body.metrics, self.content_width, ) else: @@ -148,7 +154,7 @@ def _estimate_height(self) -> int: if self.result.text: height += self._estimate_text_height( self.result.text, - assets.FONTS.body.metrics, + FONTS.body.metrics, self.content_width, ) height += self.SECTION_SPACING @@ -157,7 +163,7 @@ def _estimate_height(self) -> int: if self.result.extra_info: height += self._estimate_text_height( self.result.extra_info, - assets.FONTS.muted.metrics, + FONTS.muted.metrics, self.content_width, ) height += self.SECTION_SPACING @@ -183,21 +189,21 @@ async def _render_header(self) -> None: self._image.paste(avatar, (x_pos, self.y_pos), avatar) # 文字区域 - text_x = self.PADDING + self.AVATAR_SIZE + self.AVATAR_TEXT_GAP - name_height = assets.FONTS.name.metrics.line_height + text_x = self.PADDING + AVATAR_SIZE + self.AVATAR_TEXT_GAP + name_height = FONTS.name.metrics.line_height time_str = self.result.formartted_datetime - time_height = (self.NAME_TIME_GAP + assets.FONTS.muted.metrics.line_height) if time_str else 0 + time_height = (self.NAME_TIME_GAP + FONTS.muted.metrics.line_height) if time_str else 0 text_height = name_height + time_height # 垂直居中 - text_y = self.y_pos + (self.AVATAR_SIZE - text_height) // 2 + text_y = self.y_pos + (AVATAR_SIZE - text_height) // 2 # 名称 self._draw.text( (text_x, text_y), self.result.author.name, - font=assets.FONTS.name.metrics.font, - fill=assets.FONTS.name.fill, + font=FONTS.name.metrics.font, + fill=FONTS.name.fill, ) text_y += name_height @@ -207,39 +213,39 @@ async def _render_header(self) -> None: self._draw.text( (text_x, text_y), time_str, - font=assets.FONTS.muted.metrics.font, - fill=assets.FONTS.muted.fill, + font=FONTS.muted.metrics.font, + fill=FONTS.muted.fill, ) # 平台 Logo if self.not_repost: platform_name = self.result.platform.name - if platform_name in assets.PLATFORM_LOGOS: - logo = assets.PLATFORM_LOGOS[platform_name] + if platform_name in PLATFORM_LOGOS: + logo = PLATFORM_LOGOS[platform_name] logo_x = self._image.width - self.PADDING - logo.width - logo_y = self.y_pos + (self.AVATAR_SIZE - logo.height) // 2 + logo_y = self.y_pos + (AVATAR_SIZE - logo.height) // 2 self._image.paste(logo, (logo_x, logo_y), logo) - self.y_pos += self.AVATAR_SIZE + self.SECTION_SPACING + self.y_pos += AVATAR_SIZE + self.SECTION_SPACING def _load_avatar(self, avatar_path: Path | None) -> PILImage: """加载头像(带圆形裁剪)""" if avatar_path is None or not avatar_path.exists(): - return assets.AVATAR_IMAGE + return AVATAR_IMAGE try: with Image.open(avatar_path) as img: avatar = img.convert("RGBA") avatar = avatar.resize( - (self.AVATAR_SIZE, self.AVATAR_SIZE), + (AVATAR_SIZE, AVATAR_SIZE), Image.Resampling.LANCZOS, ) except Exception: - return assets.AVATAR_IMAGE + return AVATAR_IMAGE # 圆形遮罩 - mask = Image.new("L", (self.AVATAR_SIZE, self.AVATAR_SIZE), 0) - ImageDraw.Draw(mask).ellipse((0, 0, self.AVATAR_SIZE - 1, self.AVATAR_SIZE - 1), fill=255) + mask = Image.new("L", (AVATAR_SIZE, AVATAR_SIZE), 0) + ImageDraw.Draw(mask).ellipse((0, 0, AVATAR_SIZE - 1, AVATAR_SIZE - 1), fill=255) avatar.putalpha(mask) return avatar @@ -251,9 +257,9 @@ async def _render_title(self) -> None: lines = self._wrap_text( self.result.title, self.content_width, - assets.FONTS.title.metrics, + FONTS.title.metrics, ) - self.y_pos += await self._draw_text(lines, assets.FONTS.title) + self.y_pos += await self._draw_text(lines, FONTS.title) self.y_pos += self.SECTION_SPACING async def _render_main_content(self) -> None: @@ -287,7 +293,7 @@ async def _load_cover(self) -> PILImage | None: cover_path = await cover_task.safe_get() if cover_path is None: - return Image.open(resources.random_fallback_pic()) + return Image.open(random_fallback_pic()) with Image.open(cover_path) as img: if img.mode != "RGBA": @@ -311,15 +317,15 @@ async def _load_cover(self) -> PILImage | None: btn_size = 100 btn_x, btn_y = (img.width - btn_size) // 2, (img.height - btn_size) // 2 img.paste( - assets.VIDEO_BUTTON_IMAGE, + VIDEO_BUTTON_IMAGE, (btn_x, btn_y), - assets.VIDEO_BUTTON_IMAGE, + VIDEO_BUTTON_IMAGE, ) # 视频时长 # display_duration = video_content.display_duration - # paint = assets.FONTS.muted + # paint = FONTS.muted # text_width = font.get_text_width(display_duration) # # 计算文本绘制位置 # text_x = img.width - text_width - 20 @@ -365,7 +371,7 @@ async def _render_image_grid(self) -> None: for content in display_contents: path = await content.safe_get() if path is None or not path.exists(): - path = resources.random_fallback_pic() + path = random_fallback_pic() if img := self._load_grid_image(path, len(display_contents)): images.append(img) @@ -460,7 +466,7 @@ def _draw_more_indicator( indicator_text = f"+{count}" font_size, color = 60, (255, 255, 255) # 这里统一使用默认字体 - font = ImageFont.truetype(resources.DEFAULT_FONT_PATH, font_size) + font = ImageFont.truetype(DEFAULT_FONT_PATH, font_size) text_w = font.getbbox(indicator_text)[2] text_x = x + (w - text_w) // 2 text_y = y + (h - font_size) // 2 @@ -470,7 +476,7 @@ async def _render_img_in_graphics(self, image_content: ImageContent) -> None: """渲染图片""" path = await image_content.path_task.safe_get() if path is None or not path.exists(): - path = resources.random_fallback_pic() + path = random_fallback_pic() with Image.open(path) as img: if img.width > self.content_width: @@ -489,7 +495,7 @@ async def _render_img_in_graphics(self, image_content: ImageContent) -> None: # Alt 文本 if image_content.alt: self.y_pos += self.SECTION_SPACING - paint = assets.FONTS.muted + paint = FONTS.muted text_w = paint.metrics.get_text_width(image_content.alt) text_x = self.PADDING + (self.content_width - text_w) // 2 self._draw.text( @@ -511,9 +517,9 @@ async def _render_text(self, text: str | None = None) -> None: lines = self._wrap_text( text, self.content_width, - assets.FONTS.body.metrics, + FONTS.body.metrics, ) - self.y_pos += await self._draw_text(lines, assets.FONTS.body) + self.y_pos += await self._draw_text(lines, FONTS.body) self.y_pos += self.SECTION_SPACING async def _render_extra(self) -> None: @@ -524,9 +530,9 @@ async def _render_extra(self) -> None: lines = self._wrap_text( self.result.extra_info, self.content_width, - assets.FONTS.muted.metrics, + FONTS.muted.metrics, ) - self.y_pos += await self._draw_text(lines, assets.FONTS.muted) + self.y_pos += await self._draw_text(lines, FONTS.muted) async def _render_repost(self) -> None: """渲染转发内容""" @@ -584,7 +590,7 @@ async def _draw_text(self, lines: list[str], styled: StyledFont) -> int: metrics.font, fill=styled.fill, line_height=metrics.line_height, - source=assets.EMOJI_SOURCE, + source=EMOJI_SOURCE, ) return metrics.line_height * len(lines) From 832b8aff5edd05eb0e354319d57a40a2d534acfd Mon Sep 17 00:00:00 2001 From: fllesser Date: Thu, 28 May 2026 13:42:50 +0800 Subject: [PATCH 4/4] tweak --- .../renders/common/renderer.py | 88 +++++++++---------- 1 file changed, 40 insertions(+), 48 deletions(-) diff --git a/src/nonebot_plugin_parser/renders/common/renderer.py b/src/nonebot_plugin_parser/renders/common/renderer.py index 2d0609b6..beb56f81 100644 --- a/src/nonebot_plugin_parser/renders/common/renderer.py +++ b/src/nonebot_plugin_parser/renders/common/renderer.py @@ -8,18 +8,10 @@ from nonebot import logger from apilmoji import Apilmoji +from . import assets +from .. import resources from .font import StyledFont, FontMetrics from ..base import ParseResult, ImageContent, ImageRenderer -from .assets import ( - FONTS, - AVATAR_SIZE, - AVATAR_IMAGE, - EMOJI_SOURCE, - PLATFORM_LOGOS, - VIDEO_BUTTON_IMAGE, - ensure_resources, -) -from ..resources import DEFAULT_FONT_PATH, random_fallback_pic Color = tuple[int, int, int] PILImage = Image.Image @@ -64,7 +56,7 @@ class CommonRenderer(ImageRenderer): def __init__(self, result: ParseResult, not_repost: bool = True): super().__init__(result, not_repost) - ensure_resources() + assets.ensure_resources() self.card_width: int = self.DEFAULT_CARD_WIDTH self.content_width: int = self.card_width - 2 * self.PADDING @@ -123,13 +115,13 @@ def _estimate_height(self) -> int: # 头部(头像 + 名称 + 时间) if self.result.author: - height += AVATAR_SIZE + self.SECTION_SPACING + height += assets.AVATAR_SIZE + self.SECTION_SPACING # 标题 if self.result.title: height += self._estimate_text_height( self.result.title, - FONTS.title.metrics, + assets.FONTS.title.metrics, self.content_width, ) height += self.SECTION_SPACING @@ -140,7 +132,7 @@ def _estimate_height(self) -> int: if isinstance(item, str): height += self._estimate_text_height( item, - FONTS.body.metrics, + assets.FONTS.body.metrics, self.content_width, ) else: @@ -154,7 +146,7 @@ def _estimate_height(self) -> int: if self.result.text: height += self._estimate_text_height( self.result.text, - FONTS.body.metrics, + assets.FONTS.body.metrics, self.content_width, ) height += self.SECTION_SPACING @@ -163,7 +155,7 @@ def _estimate_height(self) -> int: if self.result.extra_info: height += self._estimate_text_height( self.result.extra_info, - FONTS.muted.metrics, + assets.FONTS.muted.metrics, self.content_width, ) height += self.SECTION_SPACING @@ -189,21 +181,21 @@ async def _render_header(self) -> None: self._image.paste(avatar, (x_pos, self.y_pos), avatar) # 文字区域 - text_x = self.PADDING + AVATAR_SIZE + self.AVATAR_TEXT_GAP - name_height = FONTS.name.metrics.line_height + text_x = self.PADDING + assets.AVATAR_SIZE + self.AVATAR_TEXT_GAP + name_height = assets.FONTS.name.metrics.line_height time_str = self.result.formartted_datetime - time_height = (self.NAME_TIME_GAP + FONTS.muted.metrics.line_height) if time_str else 0 + time_height = (self.NAME_TIME_GAP + assets.FONTS.muted.metrics.line_height) if time_str else 0 text_height = name_height + time_height # 垂直居中 - text_y = self.y_pos + (AVATAR_SIZE - text_height) // 2 + text_y = self.y_pos + (assets.AVATAR_SIZE - text_height) // 2 # 名称 self._draw.text( (text_x, text_y), self.result.author.name, - font=FONTS.name.metrics.font, - fill=FONTS.name.fill, + font=assets.FONTS.name.metrics.font, + fill=assets.FONTS.name.fill, ) text_y += name_height @@ -213,39 +205,39 @@ async def _render_header(self) -> None: self._draw.text( (text_x, text_y), time_str, - font=FONTS.muted.metrics.font, - fill=FONTS.muted.fill, + font=assets.FONTS.muted.metrics.font, + fill=assets.FONTS.muted.fill, ) # 平台 Logo if self.not_repost: platform_name = self.result.platform.name - if platform_name in PLATFORM_LOGOS: - logo = PLATFORM_LOGOS[platform_name] + if platform_name in assets.PLATFORM_LOGOS: + logo = assets.PLATFORM_LOGOS[platform_name] logo_x = self._image.width - self.PADDING - logo.width - logo_y = self.y_pos + (AVATAR_SIZE - logo.height) // 2 + logo_y = self.y_pos + (assets.AVATAR_SIZE - logo.height) // 2 self._image.paste(logo, (logo_x, logo_y), logo) - self.y_pos += AVATAR_SIZE + self.SECTION_SPACING + self.y_pos += assets.AVATAR_SIZE + self.SECTION_SPACING def _load_avatar(self, avatar_path: Path | None) -> PILImage: """加载头像(带圆形裁剪)""" if avatar_path is None or not avatar_path.exists(): - return AVATAR_IMAGE + return assets.AVATAR_IMAGE try: with Image.open(avatar_path) as img: avatar = img.convert("RGBA") avatar = avatar.resize( - (AVATAR_SIZE, AVATAR_SIZE), + (assets.AVATAR_SIZE, assets.AVATAR_SIZE), Image.Resampling.LANCZOS, ) except Exception: - return AVATAR_IMAGE + return assets.AVATAR_IMAGE # 圆形遮罩 - mask = Image.new("L", (AVATAR_SIZE, AVATAR_SIZE), 0) - ImageDraw.Draw(mask).ellipse((0, 0, AVATAR_SIZE - 1, AVATAR_SIZE - 1), fill=255) + mask = Image.new("L", (assets.AVATAR_SIZE, assets.AVATAR_SIZE), 0) + ImageDraw.Draw(mask).ellipse((0, 0, assets.AVATAR_SIZE - 1, assets.AVATAR_SIZE - 1), fill=255) avatar.putalpha(mask) return avatar @@ -257,9 +249,9 @@ async def _render_title(self) -> None: lines = self._wrap_text( self.result.title, self.content_width, - FONTS.title.metrics, + assets.FONTS.title.metrics, ) - self.y_pos += await self._draw_text(lines, FONTS.title) + self.y_pos += await self._draw_text(lines, assets.FONTS.title) self.y_pos += self.SECTION_SPACING async def _render_main_content(self) -> None: @@ -293,7 +285,7 @@ async def _load_cover(self) -> PILImage | None: cover_path = await cover_task.safe_get() if cover_path is None: - return Image.open(random_fallback_pic()) + return Image.open(resources.random_fallback_pic()) with Image.open(cover_path) as img: if img.mode != "RGBA": @@ -317,15 +309,15 @@ async def _load_cover(self) -> PILImage | None: btn_size = 100 btn_x, btn_y = (img.width - btn_size) // 2, (img.height - btn_size) // 2 img.paste( - VIDEO_BUTTON_IMAGE, + assets.VIDEO_BUTTON_IMAGE, (btn_x, btn_y), - VIDEO_BUTTON_IMAGE, + assets.VIDEO_BUTTON_IMAGE, ) # 视频时长 # display_duration = video_content.display_duration - # paint = FONTS.muted + # paint = assets.FONTS.muted # text_width = font.get_text_width(display_duration) # # 计算文本绘制位置 # text_x = img.width - text_width - 20 @@ -371,7 +363,7 @@ async def _render_image_grid(self) -> None: for content in display_contents: path = await content.safe_get() if path is None or not path.exists(): - path = random_fallback_pic() + path = resources.random_fallback_pic() if img := self._load_grid_image(path, len(display_contents)): images.append(img) @@ -466,7 +458,7 @@ def _draw_more_indicator( indicator_text = f"+{count}" font_size, color = 60, (255, 255, 255) # 这里统一使用默认字体 - font = ImageFont.truetype(DEFAULT_FONT_PATH, font_size) + font = ImageFont.truetype(resources.DEFAULT_FONT_PATH, font_size) text_w = font.getbbox(indicator_text)[2] text_x = x + (w - text_w) // 2 text_y = y + (h - font_size) // 2 @@ -476,7 +468,7 @@ async def _render_img_in_graphics(self, image_content: ImageContent) -> None: """渲染图片""" path = await image_content.path_task.safe_get() if path is None or not path.exists(): - path = random_fallback_pic() + path = resources.random_fallback_pic() with Image.open(path) as img: if img.width > self.content_width: @@ -495,7 +487,7 @@ async def _render_img_in_graphics(self, image_content: ImageContent) -> None: # Alt 文本 if image_content.alt: self.y_pos += self.SECTION_SPACING - paint = FONTS.muted + paint = assets.FONTS.muted text_w = paint.metrics.get_text_width(image_content.alt) text_x = self.PADDING + (self.content_width - text_w) // 2 self._draw.text( @@ -517,9 +509,9 @@ async def _render_text(self, text: str | None = None) -> None: lines = self._wrap_text( text, self.content_width, - FONTS.body.metrics, + assets.FONTS.body.metrics, ) - self.y_pos += await self._draw_text(lines, FONTS.body) + self.y_pos += await self._draw_text(lines, assets.FONTS.body) self.y_pos += self.SECTION_SPACING async def _render_extra(self) -> None: @@ -530,9 +522,9 @@ async def _render_extra(self) -> None: lines = self._wrap_text( self.result.extra_info, self.content_width, - FONTS.muted.metrics, + assets.FONTS.muted.metrics, ) - self.y_pos += await self._draw_text(lines, FONTS.muted) + self.y_pos += await self._draw_text(lines, assets.FONTS.muted) async def _render_repost(self) -> None: """渲染转发内容""" @@ -590,7 +582,7 @@ async def _draw_text(self, lines: list[str], styled: StyledFont) -> int: metrics.font, fill=styled.fill, line_height=metrics.line_height, - source=EMOJI_SOURCE, + source=assets.EMOJI_SOURCE, ) return metrics.line_height * len(lines)