zhenxun_bot/zhenxun/services/renderer/engines.py

222 lines
6.9 KiB
Python
Raw Normal View History

from abc import ABC, abstractmethod
from pathlib import Path
import aiofiles
from jinja2 import Environment
import markdown
from nonebot_plugin_htmlrender import html_to_pic
from pydantic import BaseModel
from zhenxun.configs.path_config import THEMES_PATH
from zhenxun.services.log import logger
from .models import Theme
THEME_PATH = THEMES_PATH
RESOURCE_ROOT = THEMES_PATH.parent
class BaseEngine(ABC):
"""渲染引擎的抽象基类。"""
@abstractmethod
async def render(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment | None" = None,
extra_css_paths: list[Path] | None = None,
custom_css_path: Path | None = None,
**kwargs,
) -> bytes:
"""所有引擎都必须实现的渲染方法。"""
pass
class BaseHtmlRenderingEngine(BaseEngine):
"""
一个专门用于处理HTML到图片转换的引擎基类
它使用模板方法模式定义了渲染的固定流程
并将具体的HTML内容生成委托给子类的抽象方法 `get_html_content`
"""
@abstractmethod
async def get_html_content(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment",
extra_css_paths: list[Path] | None,
custom_css_path: Path | None,
frameless: bool,
**kwargs,
) -> str:
"""
[抽象方法] 子类必须实现此方法以生成最终的HTML字符串
"""
pass
async def render(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment | None" = None,
extra_css_paths: list[Path] | None = None,
custom_css_path: Path | None = None,
**kwargs,
) -> bytes:
"""
[通用渲染流程] 调用 `get_html_content` 获取HTML然后调用 `html_to_pic` 生成图片
"""
if not jinja_env:
raise ValueError("HTML渲染器需要一个有效的Jinja2环境实例。")
frameless = kwargs.pop("frameless", False)
html_content = await self.get_html_content(
template_name,
data,
theme,
jinja_env,
extra_css_paths,
custom_css_path,
frameless=frameless,
**kwargs,
)
base_url_for_browser = RESOURCE_ROOT.absolute().as_uri()
if not base_url_for_browser.endswith("/"):
base_url_for_browser += "/"
pages_config = {
"viewport": kwargs.pop("viewport", {"width": 800, "height": 10}),
"base_url": base_url_for_browser,
}
final_screenshot_kwargs = kwargs.copy()
final_screenshot_kwargs.update(pages_config)
return await html_to_pic(
html=html_content,
template_path=base_url_for_browser,
**final_screenshot_kwargs,
)
class HtmlRenderer(BaseHtmlRenderingEngine):
"""使用 nonebot-plugin-htmlrender 渲染HTML模板的引擎。"""
async def get_html_content(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment",
extra_css_paths: list[Path] | None,
custom_css_path: Path | None,
frameless: bool,
**kwargs,
) -> str:
def asset_loader(asset_path: str) -> str:
current_theme_asset = theme.assets_dir / asset_path
if current_theme_asset.exists():
return current_theme_asset.relative_to(RESOURCE_ROOT).as_posix()
default_theme_asset = theme.default_assets_dir / asset_path
if default_theme_asset.exists():
return default_theme_asset.relative_to(RESOURCE_ROOT).as_posix()
logger.warning(
f"资源文件在主题 '{theme.name}''default' 中均未找到: {asset_path}"
)
return ""
extra_css_content = ""
if extra_css_paths:
css_contents = []
for path in extra_css_paths:
if path.exists():
async with aiofiles.open(path, encoding="utf-8") as f:
css_contents.append(await f.read())
extra_css_content = "\n".join(css_contents)
template_context = {
"data": data,
"extra_css": extra_css_content,
"frameless": frameless,
"theme": {
"name": theme.name,
"palette": theme.palette,
"asset": asset_loader,
},
}
template = jinja_env.get_template(template_name)
return await template.render_async(**template_context)
class MarkdownEngine(BaseHtmlRenderingEngine):
"""在服务端渲染 Markdown 为 HTML然后截图的引擎。"""
async def get_html_content(
self,
template_name: str,
data: BaseModel | dict | None,
theme: Theme,
jinja_env: "Environment",
extra_css_paths: list[Path] | None,
custom_css_path: Path | None,
frameless: bool,
**kwargs,
) -> str:
if isinstance(data, BaseModel):
raw_md = getattr(data, "markdown", "") if hasattr(data, "markdown") else ""
else:
raw_md = (data or {}).get("markdown", "")
md_html = markdown.markdown(
raw_md,
extensions=[
"pymdownx.tasklist",
"tables",
"fenced_code",
"codehilite",
"mdx_math",
"pymdownx.tilde",
],
extension_configs={"mdx_math": {"enable_dollar_delimiter": True}},
)
final_css_content = ""
if custom_css_path and custom_css_path.exists():
logger.debug(f"正在为 Markdown 渲染加载自定义样式: {custom_css_path}")
async with aiofiles.open(custom_css_path, encoding="utf-8") as f:
final_css_content = await f.read()
else:
css_paths = [
theme.default_assets_dir / "css/markdown/github-light.css",
theme.default_assets_dir / "css/markdown/pygments-default.css",
]
css_contents = []
for path in css_paths:
if path.exists():
async with aiofiles.open(path, encoding="utf-8") as f:
css_contents.append(await f.read())
final_css_content = "\n".join(css_contents)
template_context = {
"data": data,
"theme_css": theme.style_css,
"custom_style_css": final_css_content,
"md_html": md_html,
"extra_css": "",
"frameless": frameless,
"theme": {"name": theme.name},
}
template = jinja_env.get_template(template_name)
return await template.render_async(**template_context)