Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions src/nonebot_plugin_parser/renders/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import importlib

from nonebot import logger, get_driver
from nonebot import logger

from .base import BaseRenderer
from .common import CommonRenderer
Expand Down Expand Up @@ -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()
4 changes: 4 additions & 0 deletions src/nonebot_plugin_parser/renders/common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .assets import load_resources, ensure_resources
from .renderer import CommonRenderer

__all__ = ["CommonRenderer", "ensure_resources", "load_resources"]
103 changes: 103 additions & 0 deletions src/nonebot_plugin_parser/renders/common/assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from __future__ import annotations

from PIL import Image
from nonebot import logger, get_driver
from apilmoji import EmojiCDNSource

from .. import resources
from .font import CardFonts, CardTheme
from ...config import pconfig

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),
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
86 changes: 86 additions & 0 deletions src/nonebot_plugin_parser/renders/common/font.py
Original file line number Diff line number Diff line change
@@ -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),
)
Loading
Loading