diff --git a/Dockerfile b/Dockerfile index 7e0944e..e8bb3a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,16 @@ +FROM alpine:latest AS artifacts + +RUN apk add --no-cache curl xz +WORKDIR /tmp + +RUN mkdir -p /artifacts +RUN curl -L -o typst.tar.xz "https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz" \ + && tar -xJf typst.tar.xz \ + && mv typst-x86_64-unknown-linux-musl/typst /artifacts + +RUN chmod -R +x /artifacts/ + + FROM python:3.13-slim AS base ENV VIRTUAL_ENV=/app/.venv \ @@ -38,6 +51,7 @@ RUN uv sync --no-install-project FROM base AS runtime COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} +COPY --from=artifacts /artifacts/ /usr/local/bin/ WORKDIR /app diff --git a/konabot/common/path.py b/konabot/common/path.py index dc73d0f..11b3052 100644 --- a/konabot/common/path.py +++ b/konabot/common/path.py @@ -5,6 +5,7 @@ FONTS_PATH = ASSETS_PATH / "fonts" SRC_PATH = Path(__file__).resolve().parent.parent DATA_PATH = SRC_PATH.parent / "data" +TMP_PATH = DATA_PATH / "tmp" LOG_PATH = DATA_PATH / "logs" CONFIG_PATH = DATA_PATH / "config" @@ -21,4 +22,5 @@ if not LOG_PATH.exists(): LOG_PATH.mkdir() CONFIG_PATH.mkdir(exist_ok=True) +TMP_PATH.mkdir(exist_ok=True) diff --git a/konabot/docs/user/typst.txt b/konabot/docs/user/typst.txt new file mode 100644 index 0000000..37dd8c7 --- /dev/null +++ b/konabot/docs/user/typst.txt @@ -0,0 +1,4 @@ +# Typst 渲染 + +只需使用 `typst ...` 就可以渲染 Typst 了 + diff --git a/konabot/plugins/typst/__init__.py b/konabot/plugins/typst/__init__.py new file mode 100644 index 0000000..16c134f --- /dev/null +++ b/konabot/plugins/typst/__init__.py @@ -0,0 +1,97 @@ +import asyncio +import subprocess + +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import cast + +from loguru import logger +from nonebot import on_command +from nonebot.adapters import Event, Bot +from nonebot_plugin_alconna import UniMessage, UniMsg +from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent +from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot +from nonebot.adapters.onebot.v11.message import Message as OB11Message + +from konabot.common.longtask import DepLongTaskTarget +from konabot.common.path import TMP_PATH + + +TEMPLATE_PATH = Path(__file__).parent / "template.typ" +TEMPLATE = TEMPLATE_PATH.read_text() + + +def render_sync(code: str) -> bytes: + with TemporaryDirectory(dir=TMP_PATH) as tmpdirname: + temp_dir = Path(tmpdirname).resolve() + temp_typ = temp_dir / "page.typ" + temp_typ.write_text(TEMPLATE + "\n\n" + code) + + cmd = ["typst", "compile", temp_typ.name, "--format", "png", "--root", temp_dir] + + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=temp_dir.resolve(), timeout=50 + ) + logger.info( + f"渲染了 Typst " + f"STDOUT={result.stdout} " + f"STDERR={result.stderr} " + f"RETURNCODE={result.returncode}" + ) + + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, cmd, result.stdout, result.stderr + ) + + result_png = temp_dir / "page.png" + if not result_png.exists(): + raise FileNotFoundError("Typst 没有输出图片文件") + + return result_png.read_bytes() + + +async def render(code: str) -> bytes: + task = asyncio.to_thread(lambda: render_sync(code)) + return await task + + +cmd = on_command("typst") + + +@cmd.handle() +async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget): + typst_code = "" + if isinstance(evt, OB11MessageEvent): + if evt.reply is not None: + typst_code = evt.reply.message.extract_plain_text() + else: + for seg in evt.get_message(): + if seg.type == 'reply': + msgid = seg.get('id') + if msgid is not None: + msg2data = await cast(OB11Bot, bot).get_msg(message_id=msgid) + typst_code = OB11Message(msg2data.get("message")).extract_plain_text() + + typst_code += msg.extract_plain_text().removeprefix("typst").strip() + + if len(typst_code) == 0: + return + + try: + res = await render(typst_code) + except FileNotFoundError as e: + await target.send_message("渲染出错:内部错误") + raise e from e + except subprocess.CalledProcessError as e: + await target.send_message("渲染出错,以下是输出消息:\n\n" + e.stderr) + return + except TimeoutError: + await target.send_message("渲染出错:渲染超时") + return + except PermissionError as e: + await target.send_message("渲染出错:内部错误") + raise e from e + + await target.send_message(UniMessage.image(raw=res), at=False) + diff --git a/konabot/plugins/typst/template.typ b/konabot/plugins/typst/template.typ new file mode 100644 index 0000000..70e3674 --- /dev/null +++ b/konabot/plugins/typst/template.typ @@ -0,0 +1,5 @@ +#import "@preview/cetz:0.4.2" + +#set page(width: auto, height: auto, margin: (x: 10pt, y: 10pt)) +#set text(font: ("Noto Sans CJK SC")) +