├── tests ├── __init__.py ├── templates │ ├── mystyle.css │ ├── progress.html.jinja2 │ ├── text.html │ └── markdown.css ├── resources │ └── test_template_filter.png ├── conftest.py ├── test_deprecated_decorator.py ├── test_install.py ├── test_process.py ├── test_htmlrender.py └── test_browser.py ├── .python-version ├── example ├── entrypoint.sh ├── docker-compose.yaml ├── .env ├── plugins │ └── render │ │ ├── templates │ │ ├── mystyle.css │ │ ├── text.html │ │ ├── progress.html.jinja2 │ │ └── markdown.css │ │ ├── utils.py │ │ ├── html2pic.html │ │ └── __init__.py └── pyproject.toml ├── docs ├── html2pic.png ├── md2pic.png ├── text2pic.png ├── template2pic.png └── example.md ├── .dockerignore ├── nonebot_plugin_htmlrender ├── templates │ ├── text.css │ ├── text.html │ ├── markdown.html │ ├── katex │ │ ├── mathtex-script-type.min.js │ │ └── mhchem.min.js │ ├── pygments-default.css │ └── github-markdown-light.css ├── consts.py ├── __init__.py ├── signal.py ├── config.py ├── process.py ├── utils.py ├── install.py ├── browser.py └── data_source.py ├── .github └── workflows │ ├── codecov.yml │ ├── docker_image.yml │ ├── publish.yml │ └── _test-os-arch.yml ├── .pre-commit-config.yaml ├── LICENSE ├── docker-compose.yaml ├── dockerfile ├── entrypoint.sh ├── .gitignore ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /example/entrypoint.sh: -------------------------------------------------------------------------------- 1 | ../entrypoint.sh -------------------------------------------------------------------------------- /example/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | ../docker-compose.yaml -------------------------------------------------------------------------------- /example/.env: -------------------------------------------------------------------------------- 1 | DRIVER=~fastapi 2 | HOST=0.0.0.0 3 | PORT=9012 4 | COMMAND_START=["/"] 5 | -------------------------------------------------------------------------------- /docs/html2pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kexue-z/nonebot-plugin-htmlrender/HEAD/docs/html2pic.png -------------------------------------------------------------------------------- /docs/md2pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kexue-z/nonebot-plugin-htmlrender/HEAD/docs/md2pic.png -------------------------------------------------------------------------------- /docs/text2pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kexue-z/nonebot-plugin-htmlrender/HEAD/docs/text2pic.png -------------------------------------------------------------------------------- /docs/template2pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kexue-z/nonebot-plugin-htmlrender/HEAD/docs/template2pic.png -------------------------------------------------------------------------------- /tests/templates/mystyle.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: yellow; 3 | } 4 | 5 | p { 6 | color: blue; 7 | } -------------------------------------------------------------------------------- /example/plugins/render/templates/mystyle.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: yellow; 3 | } 4 | 5 | p { 6 | color: blue; 7 | } -------------------------------------------------------------------------------- /tests/resources/test_template_filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kexue-z/nonebot-plugin-htmlrender/HEAD/tests/resources/test_template_filter.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | docs/ 3 | example/ 4 | nonebot_plugin_htmlrender/ 5 | tests/ 6 | .gitignore 7 | .pre-commit-config.yaml 8 | docker-compose.yaml 9 | LICENSE 10 | *.md 11 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/templates/text.css: -------------------------------------------------------------------------------- 1 | .text { 2 | margin-top: 5px; 3 | word-break: break-all; 4 | white-space: pre-wrap; 5 | } 6 | 7 | .main-box { 8 | padding: 5px; 9 | } -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/templates/text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if css %} 4 | 7 | {% endif %} 8 | 9 | 10 |
11 |
{{ text }}
12 |
13 | 14 | -------------------------------------------------------------------------------- /example/plugins/render/utils.py: -------------------------------------------------------------------------------- 1 | from nonebot.log import logger 2 | 3 | 4 | def count_to_color(count: str) -> str: 5 | logger.debug(f"Filtering count: {count}") 6 | if count == "1": 7 | return "#facc15" 8 | elif count == "2": 9 | return "#f87171" 10 | elif count == "3": 11 | return "#c084fc" 12 | else: 13 | return "#60a5fa" 14 | -------------------------------------------------------------------------------- /tests/templates/progress.html.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test 7 | 8 | 9 | {% for count in counts %} 10 |

11 | {% endfor %} 12 | 13 | -------------------------------------------------------------------------------- /tests/templates/text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if css %} 4 | 7 | {% else %} 8 | 16 | {% endif %} 17 | 18 | 19 |
20 | {% for text in text_list %} 21 |
{{ text }}
22 | {% endfor %} 23 |
24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: [ master, dev , feat/*] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | per-python: 11 | name: Python ${{ matrix.python-version }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] 16 | 17 | uses: ./.github/workflows/_test-os-arch.yml 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | secrets: inherit 21 | -------------------------------------------------------------------------------- /example/plugins/render/templates/text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if css %} 4 | 7 | {% else %} 8 | 16 | {% endif %} 17 | 18 | 19 |
20 | {% for text in text_list %} 21 |
{{ text }}
22 | {% endfor %} 23 |
24 | 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit] 2 | ci: 3 | autofix_commit_msg: ":rotating_light: auto fix by pre-commit hooks" 4 | autofix_prs: true 5 | autoupdate_branch: main 6 | autoupdate_schedule: monthly 7 | autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks" 8 | repos: 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.12.12 11 | hooks: 12 | - id: ruff-format 13 | - id: ruff-check 14 | args: [ --fix, --exit-non-zero-on-fix ] 15 | types_or: [ python, pyi ] 16 | require_serial: true 17 | -------------------------------------------------------------------------------- /example/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "example" 3 | version = "0.1.0" 4 | description = "example" 5 | readme = "README.md" 6 | requires-python = ">=3.9, <4.0" 7 | dependencies = [ 8 | "nonebot-adapter-onebot>=2.4.6", 9 | "nonebot-plugin-htmlrender>=0.6.5", 10 | "nonebot2[fastapi]>=2.4.2", 11 | "pillow>=11.2.1", 12 | ] 13 | 14 | [tool.nonebot] 15 | adapters = [ 16 | { name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11" }, 17 | ] 18 | plugins = [""] 19 | plugin_dirs = ["plugins"] 20 | builtin_plugins = ["echo"] 21 | 22 | [[tool.uv.index]] 23 | url = "https://mirrors.aliyun.com/pypi/simple" 24 | default = true 25 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/templates/markdown.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 25 | 26 | 27 | 28 |
29 | {{ md }} 30 |
31 | 32 | {{ extra }} 33 | -------------------------------------------------------------------------------- /example/plugins/render/html2pic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 菜鸟教程(runoob.com) 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 |
16 | 17 |

Look! Styles and colors

18 | 19 |
Manipulate Text
20 | 21 |
Colors 22 | Boxes 23 |
24 | 25 |
and more...
26 | 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/consts.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import os 3 | import sys 4 | 5 | 6 | @dataclass(frozen=True) 7 | class MirrorSource: 8 | name: str 9 | url: str 10 | priority: int 11 | 12 | 13 | # consts 14 | PLUGINS_GROUP = "nonebot_plugin_htmlrender" 15 | SCRIPTS_GROUP = "nonebot_plugin_htmlrender_scripts" 16 | REQUIRES_PYTHON = (3, 9) 17 | # SHELL = os.getenv("SHELL", "") 18 | WINDOWS = sys.platform.startswith("win") or (sys.platform == "cli" and os.name == "nt") 19 | # MINGW = sysconfig.get_platform().startswith("mingw") 20 | # MACOS = sys.platform == "darwin" 21 | MIRRORS = [ 22 | MirrorSource("Default", "https://playwright.azureedge.net", 1), 23 | MirrorSource("Taobao", "https://registry.npmmirror.com/-/binary/playwright", 2), 24 | ] 25 | BROWSER_ENGINE_TYPES = ["chromium", "firefox", "webkit"] 26 | BROWSER_CHANNEL_TYPES = [ 27 | "chromium", 28 | "chrome", 29 | "chrome-beta", 30 | "chrome-dev", 31 | "chrome-canary", 32 | "msedge", 33 | "msedge-beta", 34 | "msedge-dev", 35 | "msedge-canary", 36 | "firefox", 37 | "webkit", 38 | ] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 kexue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/plugins/render/templates/progress.html.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Example 7 | 8 | {# 使用 tailwindclss CDN(仅用于举例,不建议在生产环境中使用) #} 9 | 10 | 11 | 12 | {% for count in counts %} 13 |
14 |
15 |

Count: {{ count }}

16 | {{ count | count_to_color }} 17 |
18 |
19 |
23 |
24 |
25 | {% endfor %} 26 | 27 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | nonebot: 3 | image: ghcr.io/kexue-z/nonebot-plugin-htmlrender/nonebot2-playwrght-uv 4 | env_file: 5 | - .env.prod 6 | ports: 7 | # nonebot2 监听端口 8 | - "9012:9012" 9 | volumes: 10 | - .:/app 11 | 12 | # 如果使用 plugin-localstore,你应该同时挂载下面几个路径 13 | # - plugin-localstore-cache:/root/.cache/nonebot2 14 | # - plugin-localstore-data:/root/.local/share/nonebot2 15 | # - plugin-localstore-config:/root/.config/nonebot2 16 | 17 | # 挂载浏览器目录,持久化存储,同时修改下方环境变量 18 | # - /path/to/your/host/browsers:/pw-browsers 19 | 20 | environment: 21 | - HTMLRENDER_CONNECT="ws://playwright:3000" 22 | # - PLAYWRIGHT_BROWSERS_PATH=/pw-browsers 23 | 24 | playwright: 25 | image: mcr.microsoft.com/playwright:v1.52.0-noble 26 | container_name: playwright 27 | entrypoint: ["/bin/sh", "-c", "npx -y playwright@1.52.0 run-server --port 3000 --host 0.0.0.0"] 28 | volumes: 29 | # 挂载 nonebot 的文件路径,以便本地资源文件可以访问 30 | - .:/app 31 | 32 | 33 | # 如果使用 plugin-localstore,你应该同时挂载下面几个路径 34 | # volumes: 35 | # plugin-localstore-cache: 36 | # plugin-localstore-data: 37 | # plugin-localstore-config: -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import gc 2 | 3 | import anyio 4 | import nonebot 5 | from nonebug import NONEBOT_INIT_KWARGS 6 | import pytest 7 | from pytest_asyncio import is_async_test 8 | 9 | 10 | def pytest_configure(config: pytest.Config) -> None: 11 | config.stash[NONEBOT_INIT_KWARGS] = { 12 | "superusers": {"10001"}, 13 | "command_start": {""}, 14 | "log_level": "DEBUG", 15 | "htmlrender_ci_mode": True, 16 | } 17 | 18 | 19 | def pytest_collection_modifyitems(items: list[pytest.Item]): 20 | pytest_asyncio_tests = (item for item in items if is_async_test(item)) 21 | session_scope_marker = pytest.mark.asyncio(loop_scope="session") 22 | for async_test in pytest_asyncio_tests: 23 | async_test.add_marker(session_scope_marker, append=False) 24 | 25 | 26 | @pytest.fixture(scope="session", autouse=True) 27 | async def after_nonebot_init(after_nonebot_init: None): 28 | nonebot.require("nonebot_plugin_htmlrender") 29 | 30 | 31 | @pytest.fixture(scope="session", autouse=True) 32 | async def _cleanup_playwright_session(): 33 | from nonebot_plugin_htmlrender import shutdown_htmlrender 34 | 35 | yield 36 | 37 | await shutdown_htmlrender() 38 | 39 | gc.collect() 40 | gc.collect() 41 | 42 | await anyio.lowlevel.checkpoint() 43 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/templates/katex/mathtex-script-type.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t(require("katex"));else if("function"==typeof define&&define.amd)define(["katex"],t);else{var r="object"==typeof exports?t(require("katex")):t(e.katex);for(var n in r)("object"==typeof exports?exports:e)[n]=r[n]}}("undefined"!=typeof self?self:this,(function(e){return function(){"use strict";var t={771:function(t){t.exports=e}},r={};function n(e){var o=r[e];if(void 0!==o)return o.exports;var i=r[e]={exports:{}};return t[e](i,i.exports,n),i.exports}n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,{a:t}),t},n.d=function(e,t){for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var o,i,a,u={};return o=n(771),i=n.n(o),a=document.body.getElementsByTagName("script"),(a=Array.prototype.slice.call(a)).forEach((function(e){if(!e.type||!e.type.match(/math\/tex/i))return-1;var t=null!=e.type.match(/mode\s*=\s*display(;|\s|\n|$)/),r=document.createElement(t?"div":"span");r.setAttribute("class",t?"equation":"inline-equation");try{i().render(e.text,r,{displayMode:t})}catch(t){r.textContent=e.text}e.parentNode.replaceChild(r,e)})),u=u.default}()})); -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | # Use a Python image with uv 2 | FROM docker.io/library/python:3.12-slim-bookworm 3 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 4 | 5 | # Setting env 6 | ENV TZ=Asia/Shanghai \ 7 | DEBIAN_FRONTEND=noninteractive \ 8 | # Enable bytecode compilation 9 | UV_COMPILE_BYTECODE=1 \ 10 | UV_LINK_MODE=copy 11 | 12 | # use mirrors 13 | # ENV UV_DEFAULT_INDEX="https://mirrors.aliyun.com/pypi/simple" 14 | 15 | # use mirrors 16 | # RUN echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib" >> /etc/apt/sources.list\ 17 | # && echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib" >> /etc/apt/sources.list\ 18 | # && echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib" >> /etc/apt/sources.list\ 19 | # && echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib" >> /etc/apt/sources.list 20 | 21 | # install nb-cli & playwright 22 | RUN uv pip install --system nb-cli playwright 23 | 24 | # install font fonts-noto-color-emoji 25 | RUN apt-get update \ 26 | && apt-get install -y --no-install-recommends fontconfig \ 27 | && rm -rf /tmp/sarasa /tmp/sarasa.7z /var/lib/apt/lists/* 28 | 29 | # RUN playwright install --only-shell --with-deps chromium \ 30 | # && rm -rf /var/lib/apt/lists/* 31 | 32 | 33 | # Set workdir `/app` 34 | WORKDIR /app 35 | 36 | # Clean uv entrypoint 37 | ENTRYPOINT [""] 38 | 39 | CMD ["sh","-c", "./entrypoint.sh"] 40 | -------------------------------------------------------------------------------- /.github/workflows/docker_image.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload docker image 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: 11 | ${{ github.workflow }}-${{ github.ref_name }}-${{ 12 | github.event.pull_request.number || github.sha }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build: 17 | name: "build image" 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Login to GitHub Container Registry 23 | uses: docker/login-action@v3 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Set up QEMU 30 | uses: docker/setup-qemu-action@v3 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Extract metadata for Docker 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: ghcr.io/${{ github.repository }}/nonebot2-playwrght-uv 40 | tags: | 41 | type=raw,value=latest,enable={{is_default_branch}} 42 | type=ref,event=branch 43 | type=sha,format=long 44 | 45 | - name: Build and push 46 | uses: docker/build-push-action@v6 47 | with: 48 | push: ${{ github.event_name != 'pull_request' }} 49 | platforms: linux/amd64,linux/arm64 50 | tags: ${{ steps.meta.outputs.tags }} 51 | labels: ${{ steps.meta.outputs.labels }} 52 | cache-from: type=gha,scope=nonebot2-playwrght-uv 53 | cache-to: type=gha,mode=min,scope=nonebot2-playwrght-uv -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/__init__.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot import require 3 | 4 | require("nonebot_plugin_localstore") 5 | from nonebot.log import logger 6 | from nonebot.plugin import PluginMetadata 7 | 8 | from nonebot_plugin_htmlrender.browser import ( 9 | get_new_page, 10 | shutdown_htmlrender, 11 | startup_htmlrender, 12 | ) 13 | from nonebot_plugin_htmlrender.config import Config, plugin_config 14 | from nonebot_plugin_htmlrender.data_source import ( 15 | capture_element, 16 | html_to_pic, 17 | md_to_pic, 18 | template_to_html, 19 | template_to_pic, 20 | text_to_pic, 21 | ) 22 | from nonebot_plugin_htmlrender.utils import _clear_playwright_env_vars 23 | 24 | __plugin_meta__ = PluginMetadata( 25 | name="nonebot-plugin-htmlrender", 26 | description="通过浏览器渲染图片", 27 | usage="", 28 | type="library", 29 | config=Config, 30 | homepage="https://github.com/kexue-z/nonebot-plugin-htmlrender", 31 | extra={}, 32 | ) 33 | 34 | driver = nonebot.get_driver() 35 | 36 | 37 | @driver.on_startup 38 | async def init(**kwargs): 39 | logger.info("HTMLRender Starting...") 40 | await startup_htmlrender(**kwargs) 41 | logger.opt(colors=True).info( 42 | f"HTMLRender Started with {plugin_config.htmlrender_browser}." 43 | ) 44 | 45 | 46 | @driver.on_shutdown 47 | async def shutdown(): 48 | logger.info("HTMLRender Shutting down...") 49 | await shutdown_htmlrender() 50 | _clear_playwright_env_vars() 51 | logger.info("HTMLRender Shut down.") 52 | 53 | 54 | __all__ = [ 55 | "capture_element", 56 | "get_new_page", 57 | "html_to_pic", 58 | "md_to_pic", 59 | "shutdown_htmlrender", 60 | "startup_htmlrender", 61 | "template_to_html", 62 | "template_to_pic", 63 | "text_to_pic", 64 | ] 65 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | workflow_dispatch: 6 | 7 | jobs: 8 | pypi-publish: 9 | name: Upload release to PyPI 10 | runs-on: ubuntu-latest 11 | environment: release 12 | permissions: 13 | # IMPORTANT: this permission is mandatory for trusted publishing 14 | id-token: write 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Install the latest version of uv 20 | uses: astral-sh/setup-uv@v3 21 | with: 22 | enable-cache: true 23 | 24 | - name: "Set up Python" 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version-file: ".python-version" 28 | 29 | - run: uv sync 30 | shell: bash 31 | 32 | # - name: Get Version 33 | # id: version 34 | # run: | 35 | # echo "VERSION=$(uvx pdm show --version)" >> $GITHUB_OUTPUT 36 | # echo "TAG_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 37 | # echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 38 | 39 | # - name: Check Version 40 | # if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION 41 | # run: exit 1 42 | 43 | - name: Build Package 44 | run: uv build 45 | 46 | - uses: actions/upload-artifact@v4 47 | with: 48 | name: artifact 49 | path: dist/ # or path/to/artifact 50 | 51 | - name: pypi-publish 52 | uses: pypa/gh-action-pypi-publish@v1.12.3 53 | 54 | # - name: Publish Package to PyPI 55 | # run: uv publish 56 | 57 | # - name: Publish Package to GitHub Release 58 | # run: gh release create ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl -t "🔖 ${{ steps.version.outputs.TAG_NAME }}" --generate-notes 59 | # env: 60 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /docs/example.md: -------------------------------------------------------------------------------- 1 | md2pic
2 |

html格式支持和居中

3 | 4 |
5 | html格式 图片支持 6 |
7 |
8 | 9 | # 一级标题 10 | ## 二级标题 11 | ### 三级标题 12 | #### 四级标题 13 | ##### 五级标题 14 | ###### 六级标题 15 | 16 | # 文本 17 | *斜体文本* 18 | 19 | _斜体文本_ 20 | 21 | **粗体文本** 22 | 23 | __粗体文本__ 24 | 25 | ***粗斜体文本*** 26 | 27 | ___粗斜体文本___ 28 | 29 | 删除线 30 | 31 | ~~删除线~~ 32 | 33 | 下划线 34 | 35 | ~小号~字体 36 | 37 | emoji 😀😃😄😁😆😅 38 | 39 | 列表 40 | 41 | * 第一项 42 | * 第二项 43 | * 第三项 44 | 45 | 1. 第一项 46 | 2. 第二项 47 | 3. 第三项 48 | 49 | 任务列表 50 | 51 | - [X] 第一项 52 | - [ ] 第二项 53 | 54 | # 嵌套 55 | 1. 第一项: 56 | - 第一项嵌套的第一个元素 57 | - 第一项嵌套的第二个元素 58 | 2. 第二项: 59 | - 第二项嵌套的第一个元素 60 | - 第二项嵌套的第二个元素 61 | 62 | - [X] 任务 1 63 | * [X] 任务 A 64 | * [ ] 任务 B 65 | + [x] 任务 a 66 | + [ ] 任务 b 67 | + [x] 任务 c 68 | * [X] 任务 C 69 | - [ ] 任务 2 70 | - [ ] 任务 3 71 | 72 | 分割线 73 | ---- 74 | 75 | # 图片 76 | 77 | - 必须指定宽度或大小 如 `250` 或 `100%` 78 | ```html 79 | 80 | ``` 81 | 82 | # html同款标签 83 | 84 | 如 Ctrl+Alt+Del 85 | 86 | # 引用 87 | 88 | > 最外层 89 | > > 第一层嵌套 90 | > > > 第二层嵌套 91 | 92 | # 代码 93 | 94 | ```python 95 | import this 96 | ``` 97 | 行内代码 `print("nonebot")` 98 | 99 | # 表格 100 | | 左对齐 | 右对齐 | 居中对齐 | 101 | | :-----| ----: | :----: | 102 | | 单元格 | 单元格 | 单元格 | 103 | | 单元格 | 单元格 | 单元格 | 104 | 105 | # 数学公式 106 | 107 | 单行公式 108 | 109 | $$(1+x)^\alpha =1+\alpha x +\frac{\alpha (\alpha -1}{2!} x^2+\cdots+\frac{\alpha (\alpha - 1)\cdots(\alpha - n+1)}{n!}x^n+o(x^n)$$ 110 | 111 | `$$...$$` 112 | 113 | 行内公式 $f'(x_0)=\lim_{x\rightarrow x_0} \frac{f(x)-f(x_0)}{\Delta x}$ 行内公式 114 | 115 | `$...$` 116 | 117 | # 不支持 118 | - md 格式图片插入(必须使用html格式) 119 | - 某些符号会被自动转换 -------------------------------------------------------------------------------- /tests/test_deprecated_decorator.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | import warnings 3 | from warnings import WarningMessage 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def warning_catcher() -> Generator[list[WarningMessage], None, None]: 10 | """捕获警告的fixture""" 11 | with warnings.catch_warnings(record=True) as caught_warnings: 12 | warnings.simplefilter("always") 13 | yield caught_warnings 14 | warnings.resetwarnings() 15 | warnings.simplefilter("default") 16 | 17 | 18 | def test_deprecated_basic(warning_catcher: list[WarningMessage]): 19 | """测试基础装饰器用法""" 20 | from nonebot_plugin_htmlrender.utils import deprecated 21 | 22 | @deprecated 23 | def old_function() -> str: 24 | return "test" 25 | 26 | result = old_function() 27 | assert result == "test" 28 | assert len(warning_catcher) == 1 29 | assert "已废弃。" in str(warning_catcher[0].message) 30 | 31 | 32 | def test_deprecated_with_message(warning_catcher: list[WarningMessage]): 33 | """测试带消息的装饰器""" 34 | from nonebot_plugin_htmlrender.utils import deprecated 35 | 36 | test_message = "Use new_function instead" 37 | 38 | @deprecated(message=test_message) 39 | def old_function() -> str: 40 | return "test" 41 | 42 | result = old_function() 43 | assert result == "test" 44 | assert len(warning_catcher) == 1 45 | assert test_message in str(warning_catcher[0].message) 46 | 47 | 48 | def test_deprecated_with_version(warning_catcher: list[WarningMessage]): 49 | """测试带版本号的装饰器""" 50 | from nonebot_plugin_htmlrender.utils import deprecated 51 | 52 | test_version = "2.0.0" 53 | 54 | @deprecated(version=test_version) 55 | def old_function() -> str: 56 | return "test" 57 | 58 | result = old_function() 59 | assert result == "test" 60 | assert len(warning_catcher) == 1 61 | assert test_version in str(warning_catcher[0].message) 62 | -------------------------------------------------------------------------------- /.github/workflows/_test-os-arch.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | inputs: 4 | python-version: 5 | required: true 6 | type: string 7 | 8 | jobs: 9 | test: 10 | name: Test (Py ${{ inputs.python-version }} — ${{ matrix.os_label }}) 11 | runs-on: ${{ matrix.runs_on }} 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | include: 16 | - runs_on: ubuntu-latest 17 | os_label: ubuntu-latest x64 18 | arch: x64 19 | platform: linux 20 | 21 | - runs_on: macos-latest 22 | os_label: macos-latest arm64 23 | arch: arm64 24 | platform: macos 25 | 26 | - runs_on: windows-latest 27 | os_label: windows-latest x64 28 | arch: x64 29 | platform: windows 30 | 31 | - runs_on: ubuntu-24.04-arm 32 | os_label: ubuntu-24.04-arm arm64 33 | arch: arm64 34 | platform: linux 35 | 36 | env: 37 | OS: ${{ matrix.os_label }} 38 | PYTHON_VERSION: ${{ inputs.python-version }} 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - name: Set up uv 44 | uses: astral-sh/setup-uv@v6 45 | with: 46 | python-version: ${{ inputs.python-version }} 47 | enable-cache: true 48 | cache-suffix: ${{ matrix.runs_on }}-${{ inputs.python-version }} 49 | 50 | - name: Sync deps 51 | run: uv sync --locked --all-extras --dev 52 | 53 | - name: Install Playwright (Linux) 54 | if: matrix.platform == 'linux' 55 | run: uv run playwright install --with-deps && uv run playwright install --with-deps 56 | - name: Install Playwright (non-Linux) 57 | if: matrix.platform != 'linux' 58 | run: uv run playwright install && uv run playwright install 59 | 60 | - name: Run tests 61 | run: uv run pytest -s -n auto 62 | 63 | - name: Upload coverage to Codecov 64 | uses: codecov/codecov-action@v5 65 | with: 66 | env_vars: OS,PYTHON_VERSION 67 | verbose: true 68 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Font installation script 4 | 5 | set -e 6 | 7 | # Source fonts directory 8 | # FONT_SRC_DIR="/app/fonts" 9 | # Destination fonts directory 10 | # FONT_DEST_DIR="/usr/share/fonts" 11 | 12 | # Supported font file extensions 13 | # FONT_EXTS=("ttc" "ttf" "otf" "woff" "woff2") 14 | 15 | # Font installation function with existence check 16 | # install_fonts() { 17 | # local dir="$1" 18 | 19 | # # Find all font files 20 | # while IFS= read -r -d '' file; do 21 | # # Get file extension 22 | # ext="${file##*.}" 23 | 24 | # # Check if it's a supported font format 25 | # if [[ " ${FONT_EXTS[@]} " =~ " ${ext,,} " ]]; then 26 | # # Get relative path 27 | # relative_path="${file%/*}" 28 | # relative_path="${relative_path#$FONT_SRC_DIR/}" 29 | 30 | # # Create destination directory 31 | # dest_dir="$FONT_DEST_DIR/$relative_path" 32 | # mkdir -p "$dest_dir" 33 | 34 | # # Destination file path 35 | # dest_file="$dest_dir/$(basename "$file")" 36 | 37 | # # Check if file already exists 38 | # if [ ! -f "$dest_file" ]; then 39 | # # Copy font file 40 | # echo "Installing font: $file -> $dest_dir/" 41 | # install -m644 "$file" "$dest_dir/" 42 | # else 43 | # echo "Skipping existing font: $dest_file" 44 | # fi 45 | # fi 46 | # done < <(find "$dir" -type f -print0) 47 | # } 48 | 49 | # Main execution 50 | # if [ -d "$FONT_SRC_DIR" ]; then 51 | # echo "Starting font installation..." 52 | # install_fonts "$FONT_SRC_DIR" 53 | 54 | # # Update font cache 55 | # echo "Updating font cache..." 56 | # fc-cache -fv 57 | 58 | # echo "Font installation completed!" 59 | # else 60 | # echo "Error: Font directory $FONT_SRC_DIR does not exist, skipping installation. Note: Playwright might lack Chinese fonts" 61 | # fi 62 | 63 | # Playwright browsers 64 | # echo "Install Playwright browsers..." 65 | # playwright install --only-shell --with-deps chromium \ 66 | # && rm -rf /var/lib/apt/lists/* 67 | 68 | # Install your project deps 69 | uv sync --frozen --no-install-project --no-dev 70 | 71 | nb run -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *DS_Store* 2 | test 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | .pdm-python 130 | 131 | # pycharm project settings 132 | .idea/ -------------------------------------------------------------------------------- /tests/templates/markdown.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | /** 4 | * markdown.css 5 | * 6 | * This program is free software: you can redistribute it and/or modify it under 7 | * the terms of the GNU Lesser General Public License as published by the Free 8 | * Software Foundation, either version 3 of the License, or (at your option) any 9 | * later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, but WITHOUT 12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | * details. 15 | * 16 | * You should have received a copy of the GNU Lesser General Public License 17 | * along with this program. If not, see http://gnu.org/licenses/lgpl.txt. 18 | * 19 | * @project Weblog and Open Source Projects of Florian Wolters 20 | * @version GIT: $Id$ 21 | * @package xhtml-css 22 | * @author Florian Wolters 23 | * @copyright 2012 Florian Wolters 24 | * @cssdoc version 1.0-pre 25 | * @license http://gnu.org/licenses/lgpl.txt GNU Lesser General Public License 26 | * @link http://github.com/FlorianWolters/jekyll-bootstrap-theme 27 | * @media all 28 | * @valid true 29 | */ 30 | 31 | body { 32 | font-family: Helvetica, Arial, Freesans, clean, sans-serif; 33 | padding: 1em; 34 | margin: auto; 35 | max-width: 42em; 36 | background: #fefefe; 37 | } 38 | 39 | h1, h2, h3, h4, h5, h6 { 40 | font-weight: bold; 41 | } 42 | 43 | h1 { 44 | color: #000000; 45 | font-size: 28px; 46 | } 47 | 48 | h2 { 49 | border-bottom: 1px solid #CCCCCC; 50 | color: #000000; 51 | font-size: 24px; 52 | } 53 | 54 | h3 { 55 | font-size: 18px; 56 | } 57 | 58 | h4 { 59 | font-size: 16px; 60 | } 61 | 62 | h5 { 63 | font-size: 14px; 64 | } 65 | 66 | h6 { 67 | color: #777777; 68 | background-color: inherit; 69 | font-size: 14px; 70 | } 71 | 72 | hr { 73 | height: 0.2em; 74 | border: 0; 75 | color: #CCCCCC; 76 | background-color: #CCCCCC; 77 | } 78 | 79 | p, blockquote, ul, ol, dl, li, table, pre { 80 | margin: 15px 0; 81 | } 82 | 83 | code, pre { 84 | border-radius: 3px; 85 | background-color: #F8F8F8; 86 | color: inherit; 87 | } 88 | 89 | code { 90 | border: 1px solid #EAEAEA; 91 | margin: 0 2px; 92 | padding: 0 5px; 93 | } 94 | 95 | pre { 96 | border: 1px solid #CCCCCC; 97 | line-height: 1.25em; 98 | overflow: auto; 99 | padding: 6px 10px; 100 | } 101 | 102 | pre>code { 103 | border: 0; 104 | margin: 0; 105 | padding: 0; 106 | } 107 | 108 | a, a:visited { 109 | color: #4183C4; 110 | background-color: inherit; 111 | text-decoration: none; 112 | } -------------------------------------------------------------------------------- /example/plugins/render/templates/markdown.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | /** 4 | * markdown.css 5 | * 6 | * This program is free software: you can redistribute it and/or modify it under 7 | * the terms of the GNU Lesser General Public License as published by the Free 8 | * Software Foundation, either version 3 of the License, or (at your option) any 9 | * later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, but WITHOUT 12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | * details. 15 | * 16 | * You should have received a copy of the GNU Lesser General Public License 17 | * along with this program. If not, see http://gnu.org/licenses/lgpl.txt. 18 | * 19 | * @project Weblog and Open Source Projects of Florian Wolters 20 | * @version GIT: $Id$ 21 | * @package xhtml-css 22 | * @author Florian Wolters 23 | * @copyright 2012 Florian Wolters 24 | * @cssdoc version 1.0-pre 25 | * @license http://gnu.org/licenses/lgpl.txt GNU Lesser General Public License 26 | * @link http://github.com/FlorianWolters/jekyll-bootstrap-theme 27 | * @media all 28 | * @valid true 29 | */ 30 | 31 | body { 32 | font-family: Helvetica, Arial, Freesans, clean, sans-serif; 33 | padding: 1em; 34 | margin: auto; 35 | max-width: 42em; 36 | background: #fefefe; 37 | } 38 | 39 | h1, h2, h3, h4, h5, h6 { 40 | font-weight: bold; 41 | } 42 | 43 | h1 { 44 | color: #000000; 45 | font-size: 28px; 46 | } 47 | 48 | h2 { 49 | border-bottom: 1px solid #CCCCCC; 50 | color: #000000; 51 | font-size: 24px; 52 | } 53 | 54 | h3 { 55 | font-size: 18px; 56 | } 57 | 58 | h4 { 59 | font-size: 16px; 60 | } 61 | 62 | h5 { 63 | font-size: 14px; 64 | } 65 | 66 | h6 { 67 | color: #777777; 68 | background-color: inherit; 69 | font-size: 14px; 70 | } 71 | 72 | hr { 73 | height: 0.2em; 74 | border: 0; 75 | color: #CCCCCC; 76 | background-color: #CCCCCC; 77 | } 78 | 79 | p, blockquote, ul, ol, dl, li, table, pre { 80 | margin: 15px 0; 81 | } 82 | 83 | code, pre { 84 | border-radius: 3px; 85 | background-color: #F8F8F8; 86 | color: inherit; 87 | } 88 | 89 | code { 90 | border: 1px solid #EAEAEA; 91 | margin: 0 2px; 92 | padding: 0 5px; 93 | } 94 | 95 | pre { 96 | border: 1px solid #CCCCCC; 97 | line-height: 1.25em; 98 | overflow: auto; 99 | padding: 6px 10px; 100 | } 101 | 102 | pre>code { 103 | border: 0; 104 | margin: 0; 105 | padding: 0; 106 | } 107 | 108 | a, a:visited { 109 | color: #4183C4; 110 | background-color: inherit; 111 | text-decoration: none; 112 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot-plugin-htmlrender" 3 | version = "0.7.0.alpha.3" 4 | description = "通过浏览器渲染图片" 5 | readme = "README.md" 6 | authors = [{ name = "kexue", email = "x@kexue-cloud.cn" }] 7 | requires-python = ">=3.9,<4.0" 8 | license = { file = "LICENSE" } 9 | dependencies = [ 10 | "aiofiles>=0.8.0", 11 | "jinja2>=3.0.3", 12 | "markdown>=3.3.6", 13 | "nonebot-plugin-localstore>=0.7.4", 14 | "nonebot2>=2.4.2", 15 | "playwright>=1.48.0", 16 | "pygments>=2.10.0", 17 | "pymdown-extensions>=9.1", 18 | "python-markdown-math>=0.8", 19 | "tenacity>=9.1.2", 20 | ] 21 | 22 | [project.urls] 23 | "Homepage" = "https://github.com/kexue-z/nonebot-plugin-htmlrender" 24 | "Bug Tracker" = "https://github.com/kexue-z/nonebot-plugin-htmlrender/issues" 25 | 26 | [dependency-groups] 27 | dev = [ 28 | "nonebot-adapter-onebot>=2.4.6", 29 | "nonebot2[fastapi]>=2.3.3", 30 | "nonebug>=0.4.2", 31 | "pytest-cov>=5.0", 32 | "pytest-xdist>=3.6", 33 | "pytest-mock>=3.6", 34 | "pillow>=11.0.0", 35 | "pytest-asyncio>=0.24.0", 36 | ] 37 | 38 | [tool.pytest.ini_options] 39 | asyncio_default_fixture_loop_scope = "session" 40 | #addopts = "--cov=nonebot_plugin_htmlrender --cov-report=term-missing" 41 | asyncio_mode = "auto" 42 | 43 | [tool.ruff] 44 | line-length = 88 45 | target-version = "py39" 46 | 47 | [tool.ruff.format] 48 | line-ending = "lf" 49 | 50 | [tool.ruff.lint] 51 | select = [ 52 | "F", # Pyflakes 53 | "W", # pycodestyle warnings 54 | "E", # pycodestyle errors 55 | "I", # isort 56 | "UP", # pyupgrade 57 | "ASYNC", # flake8-async 58 | "C4", # flake8-comprehensions 59 | "T10", # flake8-debugger 60 | "T20", # flake8-print 61 | "PYI", # flake8-pyi 62 | "PT", # flake8-pytest-style 63 | "Q", # flake8-quotes 64 | "TID", # flake8-tidy-imports 65 | "RUF", # Ruff-specific rules 66 | ] 67 | ignore = [ 68 | "E402", # module-import-not-at-top-of-file 69 | "UP037", # quoted-annotation 70 | "RUF001", # ambiguous-unicode-character-string 71 | "RUF002", # ambiguous-unicode-character-docstring 72 | "RUF003", # ambiguous-unicode-character-comment 73 | ] 74 | 75 | [tool.ruff.lint.isort] 76 | force-sort-within-sections = true 77 | known-first-party = ["nonebot_plugin_htmlrender", "tests/*"] 78 | extra-standard-library = ["typing_extensions"] 79 | 80 | [tool.ruff.lint.flake8-pytest-style] 81 | fixture-parentheses = false 82 | mark-parentheses = false 83 | 84 | [tool.ruff.lint.pyupgrade] 85 | keep-runtime-typing = true 86 | 87 | [tool.pyright] 88 | pythonVersion = "3.9" 89 | pythonPlatform = "All" 90 | defineConstant = { PYDANTIC_V2 = true } 91 | executionEnvironments = [ 92 | { root = "./tests", extraPaths = [ 93 | "./", 94 | ] }, 95 | { root = "./" }, 96 | ] 97 | 98 | typeCheckingMode = "standard" 99 | reportShadowedImports = false 100 | disableBytesTypePromotions = true 101 | 102 | [build-system] 103 | requires = ["hatchling"] 104 | build-backend = "hatchling.build" 105 | 106 | [tool.nonebot] 107 | plugins = ["nonebot_plugin_htmlrender"] 108 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/signal.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections.abc import Generator 3 | from contextlib import contextmanager 4 | import signal 5 | import threading 6 | from types import FrameType 7 | from typing import Callable, Optional 8 | 9 | from nonebot_plugin_htmlrender.consts import WINDOWS 10 | 11 | HANDLED_SIGNALS = ( 12 | signal.SIGINT, # Unix signal 2. Sent by Ctrl+C. 13 | signal.SIGTERM, # Unix signal 15. Sent by `kill `. 14 | ) 15 | if WINDOWS: 16 | HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break. 17 | 18 | handlers: list[Callable[[int, Optional[FrameType]], None]] = [] 19 | 20 | 21 | class _ShieldContext: 22 | """信号屏蔽上下文类,管理信号屏蔽计数。 23 | 24 | 用于在信号处理中提供信号屏蔽功能,防止在特定代码块中处理信号。 25 | """ 26 | 27 | def __init__(self) -> None: 28 | """初始化信号屏蔽上下文。 29 | 30 | 设置信号屏蔽计数器为0。 31 | """ 32 | self._counter = 0 33 | 34 | def acquire(self) -> None: 35 | """增加信号屏蔽计数器。 36 | 37 | 调用此方法后,信号处理将被屏蔽,直到调用release()。 38 | """ 39 | self._counter += 1 40 | 41 | def release(self) -> None: 42 | """减少信号屏蔽计数器。 43 | 44 | 调用此方法后,信号屏蔽计数器减一,直到计数器归零,信号处理才会重新生效。 45 | """ 46 | self._counter -= 1 47 | 48 | def active(self) -> bool: 49 | """检查是否处于信号屏蔽状态。 50 | 51 | 返回: 52 | bool: 如果信号屏蔽计数器大于0,表示处于信号屏蔽状态。 53 | """ 54 | return self._counter > 0 55 | 56 | 57 | shield_context = _ShieldContext() 58 | 59 | 60 | def install_signal_handler() -> None: 61 | """安装信号处理器。 62 | 63 | 该方法会将适当的信号处理程序安装到事件循环中,确保信号在主线程中得到处理。 64 | 65 | 仅允许在主线程中安装信号处理器。 66 | """ 67 | if threading.current_thread() is not threading.main_thread(): 68 | # Signals can only be listened to from the main thread. 69 | return 70 | 71 | loop = asyncio.get_event_loop() 72 | 73 | try: 74 | for sig in HANDLED_SIGNALS: 75 | loop.add_signal_handler(sig, handle_signal, sig, None) 76 | except NotImplementedError: 77 | # Windows 78 | for sig in HANDLED_SIGNALS: 79 | signal.signal(sig, handle_signal) 80 | 81 | 82 | def handle_signal(signum: int, frame: Optional[FrameType]) -> None: 83 | """处理信号。 84 | 85 | 该方法在收到信号时被调用。如果信号屏蔽处于活动状态,则忽略信号。 86 | 87 | Args: 88 | signum (int): 信号编号。 89 | frame (Optional[FrameType]): 当前的栈帧,通常可以为None。 90 | """ 91 | if shield_context.active(): 92 | return 93 | 94 | for handler in handlers: 95 | handler(signum, frame) 96 | 97 | 98 | def register_signal_handler( 99 | handler: Callable[[int, Optional[FrameType]], None], 100 | ) -> None: 101 | """注册信号处理函数。 102 | 103 | 将信号处理函数添加到处理列表中,以便当信号触发时调用。 104 | 105 | Args: 106 | handler (Callable[[int, Optional[FrameType]], None]): 处理信号的回调函数。 107 | """ 108 | handlers.append(handler) 109 | 110 | 111 | def remove_signal_handler(handler: Callable[[int, Optional[FrameType]], None]) -> None: 112 | """移除已注册的信号处理函数。 113 | 114 | 从处理函数列表中删除指定的信号处理函数。 115 | 116 | Args: 117 | handler (Callable[[int, Optional[FrameType]], None]): 要移除的信号处理函数。 118 | """ 119 | handlers.remove(handler) 120 | 121 | 122 | @contextmanager 123 | def shield_signals() -> Generator[None, None, None]: 124 | """信号屏蔽上下文管理器。 125 | 126 | 在`with`语句中使用时,信号会被屏蔽,直到退出`with`语句时才恢复信号处理。 127 | 128 | 使用此上下文管理器可以暂时禁用信号处理,避免在特定代码块中处理信号。 129 | 130 | Examples: 131 | >>> with shield_signals(): 132 | ... pass 133 | """ 134 | shield_context.acquire() 135 | try: 136 | yield 137 | finally: 138 | shield_context.release() 139 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Optional 3 | 4 | from nonebot import get_driver, get_plugin_config 5 | from nonebot.compat import model_validator 6 | from nonebot.log import logger 7 | import nonebot_plugin_localstore as store 8 | from pydantic import BaseModel, Field 9 | 10 | from nonebot_plugin_htmlrender.consts import BROWSER_CHANNEL_TYPES, BROWSER_ENGINE_TYPES 11 | 12 | plugin_cache_dir: Path = store.get_plugin_cache_dir() 13 | plugin_config_dir: Path = store.get_plugin_config_dir() 14 | plugin_data_dir: Path = store.get_plugin_data_dir() 15 | 16 | 17 | class Config(BaseModel): 18 | """插件配置类。""" 19 | 20 | htmlrender_browser: str = Field( 21 | default="chromium", 22 | description="Playwright浏览器引擎类型,默认值为 'chromium'。", 23 | ) 24 | htmlrender_storage_path: Path = Field( 25 | default=plugin_data_dir, 26 | description="存储路径,不填则使用 `nonebot-plugin-localstore` 管理", 27 | ) 28 | htmlrender_cache_path: Path = Field( 29 | default=plugin_cache_dir, 30 | description="缓存路径,不填则使用 `nonebot-plugin-localstore` 管理", 31 | ) 32 | htmlrender_config_path: Path = Field( 33 | default=plugin_config_dir, 34 | description="配置路径,不填则使用 `nonebot-plugin-localstore` 管理", 35 | ) 36 | htmlrender_shutdown_browser_on_exit: bool = Field( 37 | default=True, 38 | description="在插件关闭时关闭浏览器实例,在连接到远程浏览器时默认为 False。", 39 | ) 40 | htmlrender_ci_mode: bool = Field( 41 | default=False, 42 | description="启用CI模式,跳过浏览器安装和环境变量", 43 | ) 44 | htmlrender_download_host: Optional[str] = Field( 45 | default=None, description="下载Playwright浏览器时的主机地址。" 46 | ) 47 | htmlrender_download_proxy: Optional[str] = Field( 48 | default=None, description="下载Playwright浏览器时的代理设置。" 49 | ) 50 | htmlrender_proxy_host: Optional[str] = Field( 51 | default=None, description="Playwright浏览器使用的代理主机地址。" 52 | ) 53 | htmlrender_proxy_host_bypass: Optional[str] = Field( 54 | default=None, description="Playwright浏览器代理的绕过地址。" 55 | ) 56 | htmlrender_browser_channel: Optional[str] = Field( 57 | default=None, description="Playwright浏览器通道类型。" 58 | ) 59 | htmlrender_browser_executable_path: Optional[Path] = Field( 60 | default=None, description="Playwright浏览器可执行文件的路径。" 61 | ) 62 | htmlrender_connect_over_cdp: Optional[str] = Field( 63 | default=None, description="通过 CDP 连接Playwright浏览器的端点地址。" 64 | ) 65 | htmlrender_connect: Optional[str] = Field( 66 | default=None, description="通过Playwright协议连接Playwright浏览器的端点地址。" 67 | ) 68 | htmlrender_browser_args: Optional[str] = Field( 69 | default=None, description="Playwright 浏览器启动参数。" 70 | ) 71 | 72 | @model_validator(mode="after") 73 | @classmethod 74 | def check_browser_channel(cls, data: Any) -> Any: 75 | browser_channel = ( 76 | data.get("htmlrender_browser_channel") 77 | if isinstance(data, dict) 78 | else getattr(data, "htmlrender_browser_channel", None) 79 | ) 80 | 81 | if browser_channel is not None and browser_channel not in BROWSER_CHANNEL_TYPES: 82 | raise ValueError( 83 | f"Invalid browser channel type. Must be one of {BROWSER_CHANNEL_TYPES}" 84 | ) 85 | return data 86 | 87 | @model_validator(mode="after") 88 | @classmethod 89 | def check_browser(cls, data: Any) -> Any: 90 | browser = ( 91 | data.get("htmlrender_browser", "chromium") 92 | if isinstance(data, dict) 93 | else getattr(data, "htmlrender_browser", "chromium") 94 | ) 95 | 96 | if browser not in BROWSER_ENGINE_TYPES: 97 | raise ValueError( 98 | f"Invalid browser type. Must be one of {BROWSER_ENGINE_TYPES}" 99 | ) 100 | return data 101 | 102 | 103 | global_config = get_driver().config 104 | plugin_config = get_plugin_config(Config) 105 | 106 | if plugin_config.htmlrender_ci_mode: 107 | logger.info( 108 | "CI mode enabled, skipping browser installation and environment variable setup." 109 | ) 110 | -------------------------------------------------------------------------------- /tests/test_install.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections.abc import AsyncGenerator 3 | import os 4 | 5 | import pytest 6 | from pytest_mock import MockerFixture 7 | 8 | 9 | @pytest.fixture 10 | async def mock_stream() -> AsyncGenerator[asyncio.StreamReader, None]: 11 | """创建模拟的数据流""" 12 | stream = asyncio.StreamReader() 13 | stream.feed_data(b"test line\n") 14 | stream.feed_data(b"|##### | 50% Progress\n") 15 | stream.feed_data(b"final line\n") 16 | stream.feed_eof() 17 | yield stream # noqa: PT022 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_check_mirror_connectivity(mocker: MockerFixture): 22 | # Mock socket connection 23 | from nonebot_plugin_htmlrender.consts import MirrorSource 24 | from nonebot_plugin_htmlrender.install import ( 25 | check_mirror_connectivity, 26 | ) 27 | 28 | mock_open_connection = mocker.patch("asyncio.open_connection") 29 | mock_open_connection.return_value = (None, None) 30 | 31 | result = await check_mirror_connectivity(timeout=1) 32 | assert isinstance(result, (MirrorSource, type(None))) 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_download_context(mocker: MockerFixture): 37 | from nonebot_plugin_htmlrender.consts import MirrorSource 38 | from nonebot_plugin_htmlrender.install import ( 39 | download_context, 40 | ) 41 | 42 | mocker.patch( 43 | "nonebot_plugin_htmlrender.install.check_mirror_connectivity", 44 | return_value=MirrorSource("test", "http://test.com", 1), 45 | ) 46 | 47 | async with download_context(): 48 | assert "PLAYWRIGHT_DOWNLOAD_HOST" in os.environ 49 | 50 | assert "PLAYWRIGHT_DOWNLOAD_HOST" not in os.environ 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_read_stream(mock_stream): 55 | from nonebot_plugin_htmlrender.install import ( 56 | read_stream, 57 | ) 58 | 59 | received_lines = [] 60 | 61 | async def callback(line: str): 62 | received_lines.append(line) 63 | 64 | output = await read_stream(mock_stream, callback) 65 | 66 | assert "test line" in output 67 | assert any("50% Progress" in line for line in received_lines) 68 | assert "final line" in output 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_execute_install_command(mocker: MockerFixture, mock_stream): 73 | # Mock process creation 74 | from nonebot_plugin_htmlrender.install import ( 75 | execute_install_command, 76 | ) 77 | 78 | mock_process = mocker.AsyncMock() 79 | mock_process.returncode = 0 80 | mock_process.stdout = mock_stream 81 | mock_process.stderr = mock_stream 82 | 83 | mocker.patch( 84 | "nonebot_plugin_htmlrender.install.create_process", return_value=mock_process 85 | ) 86 | 87 | success, message = await execute_install_command(timeout=5) 88 | assert success 89 | assert "Installation completed" in message 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_execute_install_command_timeout(mocker: MockerFixture, mock_stream): 94 | """测试安装超时场景""" 95 | from nonebot_plugin_htmlrender.install import execute_install_command 96 | 97 | mock_process = mocker.AsyncMock() 98 | mock_process.stdout = mock_stream 99 | mock_process.stderr = mock_stream 100 | mock_process.returncode = None 101 | mock_process.pid = 12345 102 | 103 | mock_terminate = mocker.patch( 104 | "nonebot_plugin_htmlrender.install.terminate_process", return_value=None 105 | ) 106 | mocker.patch("asyncio.gather", side_effect=asyncio.TimeoutError()) 107 | mocker.patch( 108 | "nonebot_plugin_htmlrender.install.create_process", return_value=mock_process 109 | ) 110 | 111 | success, message = await execute_install_command(timeout=1) 112 | 113 | assert not success 114 | assert message == "Timed out (1s)" 115 | mock_terminate.assert_called_once_with(mock_process) 116 | 117 | 118 | @pytest.mark.asyncio 119 | async def test_install_browser(mocker: MockerFixture): 120 | # Mock execute_install_command 121 | from nonebot_plugin_htmlrender.install import ( 122 | install_browser, 123 | ) 124 | 125 | mocker.patch( 126 | "nonebot_plugin_htmlrender.install.execute_install_command", 127 | return_value=(True, "安装完成"), 128 | ) 129 | 130 | result = await install_browser(timeout=5) 131 | assert result is True 132 | -------------------------------------------------------------------------------- /example/plugins/render/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | 3 | require("nonebot_plugin_htmlrender") 4 | # 注意顺序,先require再 from ... import ... 5 | # 注意顺序,先require再 from ... import ... 6 | # 注意顺序,先require再 from ... import ... 7 | import io 8 | 9 | # 注意顺序,先require再 from ... import ... 10 | # 注意顺序,先require再 from ... import ... 11 | # 注意顺序,先require再 from ... import ... 12 | from nonebot import on_command 13 | from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment 14 | from PIL import Image 15 | 16 | from nonebot_plugin_htmlrender import ( 17 | get_new_page, 18 | md_to_pic, 19 | template_to_pic, 20 | text_to_pic, 21 | ) 22 | 23 | from .utils import count_to_color 24 | 25 | # 纯文本转图片 26 | text2pic = on_command("text2pic") 27 | 28 | 29 | @text2pic.handle() 30 | async def _text2pic(bot: Bot, event: MessageEvent): 31 | msg = str(event.get_message()) 32 | 33 | # css_path 可选 34 | # from pathlib import Path 35 | # pic = await text_to_pic( 36 | # text=msg, css_path=str(Path(__file__).parent / "templates" / "markdown.css") 37 | # ) 38 | 39 | pic = await text_to_pic(text=msg) 40 | a = Image.open(io.BytesIO(pic)) 41 | a.save("text2pic.png", format="PNG") 42 | await text2pic.finish(MessageSegment.image(pic)) 43 | 44 | 45 | # 加载本地 html 方法 46 | html2pic = on_command("html2pic") 47 | 48 | 49 | @html2pic.handle() 50 | async def _html2pic(bot: Bot, event: MessageEvent): 51 | from pathlib import Path 52 | 53 | # html 可使用本地资源 54 | async with get_new_page(viewport={"width": 300, "height": 300}) as page: 55 | await page.goto( 56 | "file://" + (str(Path(__file__).parent / "html2pic.html")), 57 | wait_until="networkidle", 58 | ) 59 | pic = await page.screenshot(full_page=True, path="./html2pic.png") 60 | 61 | await html2pic.finish(MessageSegment.image(pic)) 62 | 63 | 64 | # 使用 template2pic 加载模板 65 | template2pic = on_command("template2pic") 66 | 67 | 68 | @template2pic.handle() 69 | async def _template2pic(bot: Bot, event: MessageEvent): 70 | from pathlib import Path 71 | 72 | text_list = ["1", "2", "3", "4"] 73 | template_path = str(Path(__file__).parent / "templates") 74 | template_name = "text.html" 75 | # 设置模板 76 | # 模板中本地资源地址需要相对于 base_url 或使用绝对路径 77 | pic = await template_to_pic( 78 | template_path=template_path, 79 | template_name=template_name, 80 | templates={"text_list": text_list}, 81 | pages={ 82 | "viewport": {"width": 600, "height": 300}, 83 | "base_url": f"file://{template_path}", 84 | }, 85 | wait=2, 86 | ) 87 | 88 | a = Image.open(io.BytesIO(pic)) 89 | a.save("template2pic.png", format="PNG") 90 | 91 | await template2pic.finish(MessageSegment.image(pic)) 92 | 93 | 94 | # 使用自定义过滤器 95 | template_filter = on_command("template_filter") 96 | 97 | 98 | @template_filter.handle() 99 | async def _(): 100 | from pathlib import Path 101 | 102 | count_list = ["1", "2", "3", "4"] 103 | template_path = str(Path(__file__).parent / "templates") 104 | template_name = "progress.html.jinja2" 105 | 106 | pic = await template_to_pic( 107 | template_path=template_path, 108 | template_name=template_name, 109 | templates={"counts": count_list}, 110 | filters={"count_to_color": count_to_color}, 111 | pages={ 112 | "viewport": {"width": 600, "height": 300}, 113 | "base_url": f"file://{template_path}", 114 | }, 115 | ) 116 | 117 | a = Image.open(io.BytesIO(pic)) 118 | a.save("template_filter.png", format="PNG") 119 | 120 | await template_filter.finish(MessageSegment.image(pic)) 121 | 122 | 123 | # 使用 md2pic 124 | md2pic = on_command("md2pic") 125 | 126 | 127 | @md2pic.handle() 128 | async def _md2pic(bot: Bot, event: MessageEvent): 129 | # from pathlib import Path 130 | 131 | # 如果是直接获取消息内容 需要 unescape 132 | from nonebot.adapters.onebot.v11 import unescape 133 | 134 | msg = unescape(str(event.get_message())) 135 | 136 | # css_path 可选 137 | # pic = await md_to_pic( 138 | # md=msg, css_path=str(Path(__file__).parent / "templates" / "markdown.css") 139 | # ) 140 | 141 | pic = await md_to_pic(md=msg) 142 | 143 | a = Image.open(io.BytesIO(pic)) 144 | a.save("md2pic.png", format="PNG") 145 | 146 | await md2pic.finish(MessageSegment.image(pic)) 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nonebot-plugin-htmlrender 2 | 3 | - 通过浏览器渲染图片 4 | - 可通过查看`example`参考使用实例 5 | - 如果有安装浏览器等问题,先查看文档最底下的`常见问题`再去看 issue 有没有已经存在的 6 | 7 | ## ✨ 功能 8 | 9 | - 通过 html 和浏览器生成图片 10 | - 支持`纯文本` `markdown` 和 `jinja2` 模板输入 11 | - 通过 CSS 来控制样式 12 | 13 | ## 使用 14 | 15 | 参考[example/plugins/render/**init**.py](example/plugins/render/__init__.py) 16 | 17 | ```py 18 | from nonebot import require 19 | 20 | require("nonebot_plugin_htmlrender") 21 | # 注意顺序,先require再 from ... import ... 22 | # 注意顺序,先require再 from ... import ... 23 | # 注意顺序,先require再 from ... import ... 24 | from nonebot_plugin_htmlrender import ( 25 | text_to_pic, 26 | md_to_pic, 27 | template_to_pic, 28 | get_new_page, 29 | ) 30 | # 注意顺序,先require再 from ... import ... 31 | # 注意顺序,先require再 from ... import ... 32 | # 注意顺序,先require再 from ... import ... 33 | ``` 34 | 35 | ## 配置 36 | 37 | ### .env 配置项说明 38 | 39 | ```ini 40 | # Playwright 浏览器引擎类型 41 | # 可不填,默认为 "chromium" 42 | htmlrender_browser = "chromium" 43 | 44 | # Playwright 浏览器下载地址 45 | # 可选,用于自定义浏览器下载源 46 | htmlrender_download_host = "" 47 | 48 | # Playwright 浏览器下载代理 49 | # 可选,用于配置下载浏览器时的代理 50 | htmlrender_download_proxy = "" 51 | 52 | # Playwright 浏览器代理地址 53 | # 可选,用于配置浏览器访问时的代理 54 | # 示例: htmlrender_proxy_host = "http://127.0.0.1:7890" 55 | 56 | # Playwright 浏览器代理绕过地址 57 | # 可选,指定不使用代理的地址 58 | htmlrender_proxy_host_bypass = "" 59 | 60 | # Playwright 浏览器通道 61 | # 可选,支持以下值: 62 | # - Chrome: "chrome", "chrome-beta", "chrome-dev", "chrome-canary" 63 | # - Edge: "msedge", "msedge-beta", "msedge-dev", "msedge-canary" 64 | # 配置后可直接使用系统浏览器,无需下载 Chromium 65 | htmlrender_browser_channel = "" 66 | 67 | # Playwright 浏览器可执行文件路径 68 | # 可选,用于指定浏览器程序位置 69 | htmlrender_browser_executable_path = "" 70 | 71 | # CDP 远程调试地址 72 | # 可选,用于连接已运行的浏览器实例 73 | # 使用时需要在启动浏览器时添加参数 --remote-debugging-port=端口号 74 | htmlrender_connect_over_cdp = "http://127.0.0.1:9222" 75 | 76 | # Playwright ws 连接地址 77 | # 可选,用于连接 playwright 的 docker 容器 78 | # https://playwright.dev/docs/docker 79 | # 配套的 docker-compose.yaml 中,已经填好了 80 | htmlrender_connect="ws://playwright:3000" 81 | ``` 82 | 83 | ## 部署 84 | 85 | ### (建议)使用 docker compose 进行部署 86 | 87 | > 前提条件:你的项目使用 uv 管理 或 `pyproject.toml` 的 `dependencies` 中已经包含你的依赖 88 | > 89 | > 此方法会将 nonebot2 和 playwright 分开两个容器 90 | 91 | 1. 将 `docker-compose.yaml` & `entrypoint.sh` 复制到你自己的项目根目录下 92 | 2. 根据你的需要调整 `docker-compose.yaml` & `entrypoint.sh` 93 | 3. 拉取镜像 `docker compose pull` 94 | 4. 启动容器 `docker compose up -d` 95 | 96 | > - 查看日志 `docker compose logs -f` 97 | > - 停止/重启容器 `docker compose` 98 | 99 | ### docker 单容器部署 100 | 101 | > 前提条件:你的项目使用 uv 管理 或 `pyproject.toml` 的 `dependencies` 中已经包含你的依赖 102 | > 103 | > 此方法会将 nonebot2 和 playwright 运行在同一个容器中 104 | > 105 | > 而且你还有需要清楚你要干什么 106 | 107 | 1. 将 `docker-compose.yaml` & `entrypoint.sh` 复制到你自己的项目根目录下 108 | 2. 根据你的情况,调整指令 109 | 110 | ```bash 111 | docker run -d \ 112 | --name nonebot2 \ 113 | -v $(pwd):/app \ 114 | -p 9012:9012 \ 115 | -e "PLAYWRIGHT_BROWSERS_PATH=/app/pw-browsers" \ 116 | ghcr.io/kexue-z/nonebot-plugin-htmlrender/nonebot2-playwrght-uv sh -c "./entrypoint.sh" 117 | ``` 118 | 119 | ## 说明 120 | ### markdown 转 图片 121 | 122 | - 使用 `GitHub-light` 样式 123 | - 支持绝大部分 md 语法 124 | - 代码高亮 125 | - latex 数学公式 (感谢@[MeetWq](https://github.com/MeetWq)) 126 | - 使用 `$$...$$` 来输入独立公式 127 | - 使用 `$...$` 来输入行内公式 128 | - 图片需要使用外部连接并使用`html`格式 否则文末会超出截图范围 129 | - 图片可使用 md 语法 路径可为 `绝对路径`(建议), 或 `相对于template_path` 的路径 130 | 131 | ### 模板 转 图片 132 | 133 | - 使用 jinja2 模板引擎 134 | - 页面参数可自定义 135 | 136 | ## 🌰 栗子 137 | 138 | [example.md](docs/example.md) 139 | 140 | ### 文本转图片(同时文本里面可以包括 html 图片) 141 | 142 | ![](docs/text2pic.png) 143 | 144 | ### markdown 转图片(同时文本里面可以包括 html 图片) 145 | 146 | ![](docs/md2pic.png) 147 | 148 | ### 纯 html 转图片 149 | 150 | ![](docs/html2pic.png) 151 | 152 | ### jinja2 模板转图片 153 | 154 | ![](docs/template2pic.png) 155 | 156 | ## 特别感谢 157 | 158 | - [MeetWq](https://github.com/MeetWq) 提供数学公式支持代码和代码高亮 159 | 160 | ## 常见疑难杂症 161 | 162 | ### `playwright._impl._api_types.Error:` 初次运行时报错 163 | 164 | - 一般为缺少必要的运行环境,如中文字体等 165 | 166 | ### Ubuntu 使用 `apt` 167 | 168 | - 参考[Dao-bot Dockerfile](https://github.com/kexue-z/Dao-bot/blob/a7b35d6877b24b2bbd72039195bd1b3afebb5cf6/Dockerfile#L12-L15) 169 | 170 | ```sh 171 | apt update && apt install -y locales locales-all fonts-noto libnss3-dev libxss1 libasound2 libxrandr2 libatk1.0-0 libgtk-3-0 libgbm-dev libxshmfence1 172 | ``` 173 | 174 | - 然后设置 ENV local 175 | 176 | ```sh 177 | LANG zh_CN.UTF-8 178 | LANGUAGE zh_CN.UTF-8 179 | LC_ALL zh_CN.UTF-8 180 | ``` 181 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/templates/pygments-default.css: -------------------------------------------------------------------------------- 1 | pre { line-height: 125%; } 2 | td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } 3 | span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } 4 | td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } 5 | span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } 6 | .codehilite .hll { background-color: #ffffcc } 7 | .codehilite { background: #f8f8f8; } 8 | .codehilite .c { color: #408080; font-style: italic } /* Comment */ 9 | .codehilite .err { border: 1px solid #FF0000 } /* Error */ 10 | .codehilite .k { color: #008000; font-weight: bold } /* Keyword */ 11 | .codehilite .o { color: #666666 } /* Operator */ 12 | .codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */ 13 | .codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */ 14 | .codehilite .cp { color: #BC7A00 } /* Comment.Preproc */ 15 | .codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */ 16 | .codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */ 17 | .codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */ 18 | .codehilite .gd { color: #A00000 } /* Generic.Deleted */ 19 | .codehilite .ge { font-style: italic } /* Generic.Emph */ 20 | .codehilite .gr { color: #FF0000 } /* Generic.Error */ 21 | .codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 22 | .codehilite .gi { color: #00A000 } /* Generic.Inserted */ 23 | .codehilite .go { color: #888888 } /* Generic.Output */ 24 | .codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ 25 | .codehilite .gs { font-weight: bold } /* Generic.Strong */ 26 | .codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 27 | .codehilite .gt { color: #0044DD } /* Generic.Traceback */ 28 | .codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ 29 | .codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ 30 | .codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ 31 | .codehilite .kp { color: #008000 } /* Keyword.Pseudo */ 32 | .codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ 33 | .codehilite .kt { color: #B00040 } /* Keyword.Type */ 34 | .codehilite .m { color: #666666 } /* Literal.Number */ 35 | .codehilite .s { color: #BA2121 } /* Literal.String */ 36 | .codehilite .na { color: #7D9029 } /* Name.Attribute */ 37 | .codehilite .nb { color: #008000 } /* Name.Builtin */ 38 | .codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */ 39 | .codehilite .no { color: #880000 } /* Name.Constant */ 40 | .codehilite .nd { color: #AA22FF } /* Name.Decorator */ 41 | .codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */ 42 | .codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ 43 | .codehilite .nf { color: #0000FF } /* Name.Function */ 44 | .codehilite .nl { color: #A0A000 } /* Name.Label */ 45 | .codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ 46 | .codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ 47 | .codehilite .nv { color: #19177C } /* Name.Variable */ 48 | .codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ 49 | .codehilite .w { color: #bbbbbb } /* Text.Whitespace */ 50 | .codehilite .mb { color: #666666 } /* Literal.Number.Bin */ 51 | .codehilite .mf { color: #666666 } /* Literal.Number.Float */ 52 | .codehilite .mh { color: #666666 } /* Literal.Number.Hex */ 53 | .codehilite .mi { color: #666666 } /* Literal.Number.Integer */ 54 | .codehilite .mo { color: #666666 } /* Literal.Number.Oct */ 55 | .codehilite .sa { color: #BA2121 } /* Literal.String.Affix */ 56 | .codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */ 57 | .codehilite .sc { color: #BA2121 } /* Literal.String.Char */ 58 | .codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */ 59 | .codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ 60 | .codehilite .s2 { color: #BA2121 } /* Literal.String.Double */ 61 | .codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ 62 | .codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */ 63 | .codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ 64 | .codehilite .sx { color: #008000 } /* Literal.String.Other */ 65 | .codehilite .sr { color: #BB6688 } /* Literal.String.Regex */ 66 | .codehilite .s1 { color: #BA2121 } /* Literal.String.Single */ 67 | .codehilite .ss { color: #19177C } /* Literal.String.Symbol */ 68 | .codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */ 69 | .codehilite .fm { color: #0000FF } /* Name.Function.Magic */ 70 | .codehilite .vc { color: #19177C } /* Name.Variable.Class */ 71 | .codehilite .vg { color: #19177C } /* Name.Variable.Global */ 72 | .codehilite .vi { color: #19177C } /* Name.Variable.Instance */ 73 | .codehilite .vm { color: #19177C } /* Name.Variable.Magic */ 74 | .codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */ 75 | -------------------------------------------------------------------------------- /tests/test_process.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | async def long_running_process(): 10 | import asyncio 11 | import os 12 | 13 | command = "timeout /t 15" if os.name == "nt" else "sleep 15" 14 | proc = await asyncio.create_subprocess_shell( 15 | command, 16 | stdout=asyncio.subprocess.PIPE, 17 | stderr=asyncio.subprocess.PIPE, 18 | start_new_session=(os.name != "nt"), 19 | ) 20 | try: 21 | yield proc 22 | finally: 23 | if proc.returncode is None: 24 | proc.terminate() 25 | await proc.wait() 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_create_process_basic(): 30 | from nonebot_plugin_htmlrender.process import create_process 31 | 32 | if os.name == "nt": 33 | proc = await create_process("cmd", "/c", "echo", "test") 34 | else: 35 | proc = await create_process("echo", "test") 36 | await proc.wait() 37 | assert proc.returncode == 0 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_create_process_with_cwd(): 42 | from nonebot_plugin_htmlrender.process import create_process 43 | 44 | cwd = Path.cwd() 45 | if os.name == "nt": 46 | proc = await create_process("cmd", "/c", "cd", cwd=cwd) 47 | else: 48 | proc = await create_process("pwd", cwd=cwd) 49 | await proc.wait() 50 | assert proc.returncode == 0 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_create_process_with_pipe(): 55 | from nonebot_plugin_htmlrender.process import create_process 56 | 57 | if os.name == "nt": 58 | proc = await create_process( 59 | "cmd", "/c", "echo", "test", stdout=asyncio.subprocess.PIPE 60 | ) 61 | else: 62 | proc = await create_process("echo", "test", stdout=asyncio.subprocess.PIPE) 63 | stdout, _ = await proc.communicate() 64 | assert b"test" in stdout.lower() 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_create_process_shell_basic(): 69 | from nonebot_plugin_htmlrender.process import create_process_shell 70 | 71 | command = "echo test" 72 | proc = await create_process_shell(command) 73 | assert isinstance(proc, asyncio.subprocess.Process) 74 | await proc.wait() 75 | assert proc.returncode == 0 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_create_process_shell_with_cwd(): 80 | from nonebot_plugin_htmlrender.process import create_process_shell 81 | 82 | cwd = Path.cwd() 83 | command = "cd" if os.name == "nt" else "pwd" 84 | proc = await create_process_shell(command, cwd=cwd) 85 | await proc.wait() 86 | assert proc.returncode == 0 87 | 88 | 89 | @pytest.mark.asyncio 90 | async def test_terminate_process(long_running_process): 91 | from nonebot_plugin_htmlrender.process import terminate_process 92 | 93 | await terminate_process(long_running_process) 94 | await long_running_process.wait() 95 | assert long_running_process.returncode != 0 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_terminate_completed_process(): 100 | from nonebot_plugin_htmlrender.process import create_process, terminate_process 101 | 102 | if os.name == "nt": 103 | proc = await create_process("cmd", "/c", "echo", "test") 104 | else: 105 | proc = await create_process("echo", "test") 106 | await proc.wait() 107 | original_returncode = proc.returncode 108 | await terminate_process(proc) 109 | assert proc.returncode == original_returncode 110 | 111 | 112 | @pytest.mark.asyncio 113 | async def test_terminate_process_unix(long_running_process): 114 | from nonebot_plugin_htmlrender.process import terminate_process 115 | 116 | await terminate_process(long_running_process) 117 | await long_running_process.wait() 118 | assert long_running_process.returncode != 0 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_ensure_process_terminated_decorator(): 123 | from nonebot_plugin_htmlrender.process import ( 124 | create_process, 125 | ensure_process_terminated, 126 | ) 127 | 128 | proc = None 129 | 130 | @ensure_process_terminated 131 | async def func(): 132 | nonlocal proc 133 | command = ( 134 | ["cmd", "/c", "ping -n 20 -w 1000 127.0.0.1"] 135 | if os.name == "nt" 136 | else ["sleep", "10"] 137 | ) 138 | proc = await create_process( 139 | *command, 140 | stdout=asyncio.subprocess.DEVNULL, 141 | stderr=asyncio.subprocess.DEVNULL, 142 | ) 143 | try: 144 | await proc.wait() 145 | except asyncio.CancelledError: 146 | await terminate_process(proc) 147 | raise 148 | return proc 149 | 150 | async def terminate_process(_): 151 | if _ and _.returncode is None: 152 | _.terminate() 153 | await asyncio.shield(_.wait()) 154 | 155 | task = asyncio.create_task(func()) 156 | await asyncio.sleep(1.0) 157 | 158 | assert proc is not None, "Process was not created." 159 | assert isinstance(proc, asyncio.subprocess.Process), ( 160 | "Process is not of expected type." 161 | ) 162 | assert proc.returncode is None, "Process already terminated prematurely." 163 | 164 | task.cancel() 165 | 166 | try: 167 | await task 168 | except asyncio.CancelledError: 169 | await terminate_process(proc) 170 | 171 | await asyncio.sleep(0.5) 172 | assert proc.returncode is not None, "Process did not terminate as expected." 173 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/process.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections.abc import Coroutine 3 | from contextlib import nullcontext 4 | from functools import wraps 5 | import os 6 | from pathlib import Path 7 | import signal 8 | import subprocess 9 | from typing import IO, Any, Callable, Optional, Union 10 | from typing_extensions import ParamSpec 11 | 12 | from nonebot_plugin_htmlrender.consts import WINDOWS 13 | from nonebot_plugin_htmlrender.signal import ( 14 | register_signal_handler, 15 | remove_signal_handler, 16 | shield_signals, 17 | ) 18 | 19 | P = ParamSpec("P") 20 | 21 | 22 | def ensure_process_terminated( 23 | func: Callable[P, Coroutine[Any, Any, asyncio.subprocess.Process]], 24 | ) -> Callable[P, Coroutine[Any, Any, asyncio.subprocess.Process]]: 25 | """ 26 | 确保在函数退出时进程被终止的装饰器。 27 | 28 | Args: 29 | func: 需要被包装的函数。 30 | 31 | Examples: 32 | >>> @ensure_process_terminated 33 | ... async def func(): 34 | ... return await create_process("ls") 35 | """ 36 | tasks: set[asyncio.Task] = set() 37 | 38 | @wraps(func) 39 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> asyncio.subprocess.Process: 40 | should_exit = asyncio.Event() 41 | 42 | def shutdown(signum, frame): 43 | should_exit.set() 44 | 45 | register_signal_handler(shutdown) 46 | 47 | async def wait_for_exit(): 48 | await should_exit.wait() 49 | await terminate_process(proc) 50 | 51 | async def wait_for_finish(): 52 | await proc.wait() 53 | should_exit.set() 54 | 55 | proc = await func(*args, **kwargs) 56 | 57 | exit_task = asyncio.create_task(wait_for_exit()) 58 | tasks.add(exit_task) 59 | exit_task.add_done_callback(tasks.discard) 60 | 61 | wait_task = asyncio.create_task(wait_for_finish()) 62 | tasks.add(wait_task) 63 | wait_task.add_done_callback(tasks.discard) 64 | wait_task.add_done_callback(lambda t: remove_signal_handler(shutdown)) 65 | 66 | return proc 67 | 68 | return wrapper 69 | 70 | 71 | @ensure_process_terminated 72 | async def create_process( 73 | *args: Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"], 74 | cwd: Optional[Path] = None, 75 | stdin: Optional[Union[IO[Any], int]] = None, 76 | stdout: Optional[Union[IO[Any], int]] = None, 77 | stderr: Optional[Union[IO[Any], int]] = None, 78 | ) -> asyncio.subprocess.Process: 79 | """ 80 | 创建一个新进程。 81 | 82 | Args: 83 | *args: 进程的命令和参数。 84 | cwd: 工作目录。默认为 None。 85 | stdin: 标准输入。默认为 None。 86 | stdout: 标准输出。默认为 None。 87 | stderr: 标准错误。默认为 None。 88 | 89 | Returns: 90 | asyncio.subprocess.Process: 新进程。 91 | 92 | Examples: 93 | >>> async def func(): 94 | ... return await create_process("ls") 95 | 96 | >>> async def func(): 97 | ... return await create_process("ls", cwd="/") 98 | """ 99 | if WINDOWS: 100 | creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP 101 | return await asyncio.create_subprocess_exec( 102 | *args, 103 | cwd=cwd, 104 | stdin=stdin, 105 | stdout=stdout, 106 | stderr=stderr, 107 | creationflags=creation_flags, 108 | ) 109 | else: 110 | creation_flags = 0 111 | return await asyncio.create_subprocess_exec( 112 | *args, 113 | cwd=cwd, 114 | stdin=stdin, 115 | stdout=stdout, 116 | stderr=stderr, 117 | creationflags=creation_flags, 118 | start_new_session=True, 119 | ) 120 | 121 | 122 | @ensure_process_terminated 123 | async def create_process_shell( 124 | command: Union[str, bytes], 125 | cwd: Optional[Path] = None, 126 | stdin: Optional[Union[IO[Any], int]] = None, 127 | stdout: Optional[Union[IO[Any], int]] = None, 128 | stderr: Optional[Union[IO[Any], int]] = None, 129 | ) -> asyncio.subprocess.Process: 130 | """ 131 | 使用 shell 创建一个新进程。 132 | 133 | Args: 134 | command: 要运行的命令。 135 | cwd: 工作目录。默认为 None。 136 | stdin: 标准输入。默认为 None。 137 | stdout: 标准输出。默认为 None。 138 | stderr: 标准错误。默认为 None。 139 | 140 | Returns: 141 | asyncio.subprocess.Process: 新进程。 142 | 143 | Examples: 144 | >>> async def func(): 145 | ... return await create_process_shell("ls") 146 | 147 | >>> async def func(): 148 | ... return await create_process_shell("ls", cwd="/") 149 | """ 150 | return await asyncio.create_subprocess_shell( 151 | command, 152 | cwd=cwd, 153 | stdin=stdin, 154 | stdout=stdout, 155 | stderr=stderr, 156 | creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if WINDOWS else 0, 157 | ) 158 | 159 | 160 | async def terminate_process(process: asyncio.subprocess.Process) -> None: 161 | """ 162 | 终止一个进程。 163 | 164 | Args: 165 | process: 要终止的进程。 166 | """ 167 | if process.returncode is not None: 168 | return 169 | 170 | context = shield_signals() if WINDOWS else nullcontext() 171 | 172 | with context: 173 | if WINDOWS: 174 | os.kill(process.pid, signal.CTRL_BREAK_EVENT) 175 | else: 176 | try: 177 | pgid = os.getpgid(process.pid) 178 | os.killpg(pgid, signal.SIGTERM) 179 | except ProcessLookupError: 180 | process.terminate() 181 | 182 | try: 183 | await asyncio.wait_for(process.wait(), timeout=5.0) 184 | except asyncio.TimeoutError: 185 | if not WINDOWS: 186 | try: 187 | pgid = os.getpgid(process.pid) 188 | os.killpg(pgid, signal.SIGKILL) 189 | except ProcessLookupError: 190 | process.kill() 191 | else: 192 | process.kill() 193 | await process.wait() 194 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/utils.py: -------------------------------------------------------------------------------- 1 | from asyncio import Lock 2 | from collections.abc import Awaitable 3 | from contextlib import contextmanager 4 | from functools import wraps 5 | import os 6 | from pathlib import Path 7 | import platform 8 | import re 9 | import shutil 10 | from typing import ( 11 | Any, 12 | Callable, 13 | Optional, 14 | TypeVar, 15 | Union, 16 | overload, 17 | ) 18 | from typing_extensions import ParamSpec 19 | import warnings 20 | 21 | from nonebot.log import logger 22 | 23 | from nonebot_plugin_htmlrender.config import plugin_config 24 | 25 | P = ParamSpec("P") 26 | R = TypeVar("R") 27 | F = TypeVar("F", bound=Callable[..., Any]) 28 | 29 | 30 | @overload 31 | def deprecated(func: Callable[P, R]) -> Callable[P, R]: ... 32 | 33 | 34 | @overload 35 | def deprecated( 36 | func: None = None, 37 | *, 38 | message: Optional[str] = None, 39 | version: Optional[str] = None, 40 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... 41 | 42 | 43 | def deprecated( 44 | func: Optional[Callable[P, R]] = None, 45 | *, 46 | message: Optional[str] = None, 47 | version: Optional[str] = None, 48 | ) -> Union[Callable[P, R], Callable[[Callable[P, R]], Callable[P, R]]]: 49 | """ 50 | 一个用于标记函数为已废弃的装饰器。 51 | 52 | 可以通过两种方式使用: 53 | 1. 作为简单装饰器:@deprecated 54 | 2. 带参数的方式:@deprecated(message="...", version="...") 55 | 56 | Args: 57 | func: 需要标记为废弃的函数 58 | message: 自定义的废弃提示消息(可选) 59 | version: 标记为废弃的版本号(可选) 60 | 61 | Returns: 62 | 如果没有传递参数,返回一个装饰器; 63 | 否则返回已装饰的函数。 64 | 65 | Examples: 66 | >>> @deprecated 67 | ... def old_function(): 68 | ... pass 69 | 70 | >>> @deprecated(message="请使用 new_function() 代替", version="2.0.0") 71 | ... def another_old_function(): 72 | ... pass 73 | """ 74 | 75 | def create_wrapper(f: Callable[P, R]) -> Callable[P, R]: 76 | @wraps(f) 77 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 78 | # 生成废弃的警告信息 79 | warning_message = message or f"函数 '{f.__name__}' 已废弃。" 80 | if version: 81 | warning_message += f" (在版本 {version} 中被标记为废弃)" 82 | 83 | # 发出废弃警告 84 | warnings.warn(warning_message, category=DeprecationWarning, stacklevel=2) 85 | return f(*args, **kwargs) 86 | 87 | return wrapper 88 | 89 | return create_wrapper if func is None else create_wrapper(func) 90 | 91 | 92 | @contextmanager 93 | def suppress_and_log(): 94 | """ 95 | 一个上下文管理器,用于抑制异常并记录任何发生的异常。 96 | 97 | 该上下文管理器在特定操作(例如关闭资源)期间抑制异常,并在出现异常时将其记录下来。 98 | 99 | Examples: 100 | >>> with suppress_and_log(): 101 | >>> pass 102 | 103 | Yields: 104 | None: 没有返回值,但如果发生异常,会被捕获并记录。 105 | """ 106 | try: 107 | yield 108 | except Exception as e: 109 | # 捕获异常并记录日志 110 | logger.opt(exception=e).warning("Error occurred while closing playwright.") 111 | 112 | 113 | def proxy_settings(proxy_host: Optional[str]) -> Optional[dict]: 114 | """ 115 | 代理设置,解析提供的代理 URL,并检查是否包含用户名和密码,同时处理代理绕过。 116 | 117 | Args: 118 | proxy_host (Optional[str]): 代理主机的 URL。 119 | 120 | Returns: 121 | Optional[dict]: 代理设置。 122 | """ 123 | if not proxy_host: 124 | return None 125 | 126 | proxy_pattern = re.compile( 127 | r"^(?Phttps?|socks5?|http)://" 128 | r"(?P[^:]+):(?P[^@]+)" 129 | r"@(?P[^:/]+)(?::(?P\d+))?$", 130 | re.IGNORECASE, 131 | ) 132 | 133 | if match := proxy_pattern.match(proxy_host): 134 | proxy_info = match.groupdict() 135 | 136 | proxy_url = ( 137 | f"{proxy_info['protocol']}://" 138 | f"{proxy_info['host']}:{proxy_info['port'] or 80}" 139 | ) 140 | 141 | proxy = { 142 | "server": proxy_url, 143 | "username": proxy_info["username"], 144 | "password": proxy_info["password"], 145 | } 146 | 147 | else: 148 | proxy_url = proxy_host 149 | proxy = {"server": proxy_url} 150 | 151 | if plugin_config.htmlrender_proxy_host_bypass: 152 | proxy["bypass"] = plugin_config.htmlrender_proxy_host_bypass 153 | 154 | return proxy 155 | 156 | 157 | def with_lock(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: 158 | lock = Lock() 159 | 160 | @wraps(func) 161 | async def wrapper(*args, **kwargs) -> R: 162 | async with lock: 163 | return await func(*args, **kwargs) 164 | 165 | return wrapper 166 | 167 | 168 | def _prepare_playwright_env_vars() -> None: 169 | """ 170 | 准备启动浏览器所需的环境变量。 171 | 172 | Returns: 173 | Dict[str, str]: 包含环境变量的字典 174 | """ 175 | if ( 176 | plugin_config.htmlrender_storage_path 177 | and not plugin_config.htmlrender_browser_executable_path 178 | ): 179 | storage_path = os.path.abspath( 180 | os.path.expanduser(str(plugin_config.htmlrender_storage_path)) 181 | ) 182 | os.environ["PLAYWRIGHT_BROWSERS_PATH"] = storage_path 183 | 184 | logger.debug(f'Setting PLAYWRIGHT_BROWSERS_PATH="{storage_path}"') 185 | 186 | 187 | def _clear_playwright_env_vars() -> None: 188 | if ( 189 | plugin_config.htmlrender_storage_path 190 | and not plugin_config.htmlrender_browser_executable_path 191 | ) and "PLAYWRIGHT_BROWSERS_PATH" in os.environ: 192 | playwright_path = os.environ.pop("PLAYWRIGHT_BROWSERS_PATH") 193 | logger.debug(f'PLAYWRIGHT_BROWSERS_PATH="{playwright_path}" removed') 194 | 195 | 196 | def clean_playwright_cache() -> None: 197 | system = platform.system() 198 | home_dir = Path.home() 199 | cache_path = None 200 | 201 | if system == "Windows": 202 | cache_path = home_dir / "AppData" / "Local" / "ms-playwright" 203 | elif system == "Darwin": 204 | cache_path = home_dir / "Library" / "Caches" / "ms-playwright" 205 | elif system == "Linux": 206 | cache_path = home_dir / ".cache" / "ms-playwright" 207 | 208 | if cache_path and cache_path.exists(): 209 | try: 210 | logger.warning( 211 | "Since v0.7.0, nonebot-plugin-htmlrender has moved the Playwright" 212 | "cache path. Executable files are now stored and managed by the " 213 | "`nonebot-plugin-localstore` plugin under " 214 | f"{plugin_config.htmlrender_storage_path}. " 215 | "You can change this path via the config option " 216 | "`htmlrender_storage_path`." 217 | ) 218 | logger.info(f"Deleting Playwright directory at {cache_path}") 219 | shutil.rmtree(str(cache_path)) 220 | logger.info("Playwright was cleaned successfully.") 221 | except Exception as e: 222 | logger.error(f"Failed to delete Playwright: {e}") 223 | -------------------------------------------------------------------------------- /tests/test_htmlrender.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from nonebug import App 6 | from PIL import Image, ImageChops 7 | import pytest 8 | from pytest_mock import MockerFixture 9 | 10 | 11 | @pytest.fixture 12 | async def browser(): 13 | """启动和关闭浏览器的 fixture""" 14 | from nonebot_plugin_htmlrender import shutdown_htmlrender, startup_htmlrender 15 | 16 | await startup_htmlrender() 17 | yield 18 | await shutdown_htmlrender() 19 | 20 | 21 | @pytest.fixture 22 | def page_config() -> dict[str, Any]: 23 | """页面配置的 fixture""" 24 | return { 25 | "viewport": {"width": 600, "height": 300}, 26 | "base_url": None, 27 | } 28 | 29 | 30 | @pytest.fixture 31 | def template_resources(request: Any) -> tuple[str, str, list[str]]: 32 | """模板资源的 fixture""" 33 | template_path = str(Path(__file__).parent / "templates") 34 | 35 | template_type = getattr(request, "param", "progress") 36 | 37 | if template_type == "progress": 38 | template_name = "progress.html.jinja2" 39 | data_list = ["1", "2", "3", "4"] 40 | elif template_type == "text": 41 | template_name = "text.html" 42 | data_list = ["1", "2", "3", "4"] 43 | else: # pragma: no cover 44 | raise ValueError(f"Unsupported template type: {template_type}") 45 | 46 | return template_path, template_name, data_list 47 | 48 | 49 | @pytest.fixture 50 | def test_image() -> Image.Image: 51 | """测试图片的 fixture""" 52 | test_image_path = Path(__file__).parent / "resources" / "test_template_filter.png" 53 | return Image.open(test_image_path) 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_text_to_pic(app: App, browser: None) -> None: 58 | """测试文本转图片功能""" 59 | from nonebot_plugin_htmlrender import text_to_pic 60 | 61 | img = await text_to_pic("114514") 62 | assert isinstance(img, bytes) 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_md_to_pic(app: App, browser: None) -> None: 67 | """测试 Markdown 转图片功能""" 68 | from nonebot_plugin_htmlrender import md_to_pic 69 | 70 | img = await md_to_pic("$$114514$$") 71 | assert isinstance(img, bytes) 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_html_to_pic(app: App, browser: None) -> None: 76 | """测试 HTML 转图片功能""" 77 | from nonebot_plugin_htmlrender import html_to_pic 78 | 79 | img = await html_to_pic("

114514

") 80 | assert isinstance(img, bytes) 81 | 82 | 83 | @pytest.mark.asyncio 84 | @pytest.mark.parametrize("template_resources", ["text"], indirect=True) 85 | async def test_template_to_pic( 86 | app: App, 87 | browser: None, 88 | page_config: dict[str, Any], 89 | template_resources: tuple[str, str, list[str]], 90 | ) -> None: 91 | """测试模板转图片功能""" 92 | from nonebot_plugin_htmlrender import template_to_pic 93 | 94 | template_path, template_name, text_list = template_resources 95 | page_config["base_url"] = f"file://{template_path}" 96 | 97 | img = await template_to_pic( 98 | template_path=template_path, 99 | template_name=template_name, 100 | templates={"text_list": text_list}, 101 | pages=page_config, 102 | ) 103 | assert isinstance(img, bytes) 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_template_filter( 108 | app: App, 109 | browser: None, 110 | template_resources: tuple[str, str, list[str]], 111 | test_image: Image.Image, 112 | page_config: dict[str, Any], 113 | ) -> None: 114 | """测试模板过滤器功能""" 115 | from nonebot_plugin_htmlrender import template_to_pic 116 | 117 | def _count_to_color(count: str) -> str: 118 | if count == "1": 119 | return "#facc15" 120 | elif count == "2": 121 | return "#f87171" 122 | elif count == "3": 123 | return "#c084fc" 124 | else: 125 | return "#60a5fa" 126 | 127 | template_path, template_name, count_list = template_resources 128 | page_config["base_url"] = f"file://{template_path}" 129 | 130 | image_byte = await template_to_pic( 131 | template_path=template_path, 132 | template_name=template_name, 133 | templates={"counts": count_list}, 134 | filters={"count_to_color": _count_to_color}, 135 | pages=page_config, 136 | ) 137 | 138 | image = Image.open(BytesIO(initial_bytes=image_byte)) 139 | diff = ImageChops.difference(image, test_image) 140 | assert diff.getbbox() is None 141 | 142 | 143 | @pytest.mark.asyncio 144 | async def test_capture_element(mocker: MockerFixture) -> None: 145 | """测试网页元素捕获功能""" 146 | from nonebot_plugin_htmlrender.data_source import capture_element 147 | 148 | mock_screenshot = b"test_image_bytes" 149 | 150 | mock_locator = mocker.AsyncMock() 151 | mock_locator.screenshot.return_value = mock_screenshot 152 | 153 | mock_page = mocker.AsyncMock() 154 | mock_page.goto = mocker.AsyncMock() 155 | mock_page.on = mocker.MagicMock() 156 | mock_page.off = mocker.MagicMock() 157 | mock_page.locator = mocker.MagicMock(return_value=mock_locator) 158 | 159 | mock_cm = mocker.MagicMock() 160 | mock_cm.__aenter__ = mocker.AsyncMock(return_value=mock_page) 161 | mock_cm.__aexit__ = mocker.AsyncMock(return_value=None) 162 | 163 | mocker.patch( 164 | "nonebot_plugin_htmlrender.data_source.get_new_page", return_value=mock_cm 165 | ) 166 | 167 | result = await capture_element("https://example.com", "#target-element") 168 | 169 | assert result == mock_screenshot 170 | mock_page.goto.assert_called_once_with("https://example.com") 171 | mock_page.locator.assert_called_once_with("#target-element") 172 | mock_locator.screenshot.assert_called_once_with() 173 | 174 | mock_page.goto.reset_mock() 175 | mock_page.locator.reset_mock() 176 | mock_locator.screenshot.reset_mock() 177 | 178 | page_kwargs = {"device_scale_factor": 2.0} 179 | goto_kwargs = {"timeout": 5000} 180 | screenshot_kwargs = {"type": "jpeg", "quality": 80} 181 | 182 | result = await capture_element( 183 | "https://example.com", 184 | "//div[@id='xpath-element']", 185 | page_kwargs=page_kwargs, 186 | goto_kwargs=goto_kwargs, 187 | screenshot_kwargs=screenshot_kwargs, 188 | ) 189 | 190 | assert result == mock_screenshot 191 | mock_page.goto.assert_called_once_with("https://example.com", timeout=5000) 192 | mock_page.locator.assert_called_once_with("//div[@id='xpath-element']") 193 | mock_locator.screenshot.assert_called_once_with(type="jpeg", quality=80) 194 | 195 | 196 | @pytest.mark.asyncio 197 | async def test_capture_element_exceptions_propagate(mocker: MockerFixture) -> None: 198 | """测试网页元素捕获时的异常能正确传递""" 199 | from playwright.async_api import Error 200 | 201 | from nonebot_plugin_htmlrender.data_source import capture_element 202 | 203 | mock_cm = mocker.MagicMock() 204 | mock_cm.__aenter__ = mocker.AsyncMock(side_effect=Error("Browser error")) 205 | 206 | mocker.patch( 207 | "nonebot_plugin_htmlrender.data_source.get_new_page", return_value=mock_cm 208 | ) 209 | 210 | with pytest.raises(Error) as exc_info: 211 | await capture_element("https://example.com", "#element") 212 | 213 | assert "Browser error" in str(exc_info.value) 214 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/install.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections.abc import AsyncIterator, Awaitable 3 | from contextlib import asynccontextmanager 4 | import os 5 | from typing import Callable, Optional 6 | from urllib.parse import urlparse 7 | import sys 8 | 9 | from nonebot import logger 10 | 11 | from nonebot_plugin_htmlrender.config import plugin_config 12 | from nonebot_plugin_htmlrender.consts import MIRRORS, MirrorSource 13 | from nonebot_plugin_htmlrender.process import create_process, terminate_process 14 | from nonebot_plugin_htmlrender.signal import install_signal_handler 15 | 16 | 17 | async def check_mirror_connectivity(timeout: int = 5) -> Optional[MirrorSource]: 18 | """检查镜像源的可用性并返回最佳镜像源。 19 | 20 | Args: 21 | timeout (int): 连接超时时间。 22 | 23 | Returns: 24 | Optional[MirrorSource]: 可用的最佳镜像源,如果没有可用镜像则返回 None。 25 | """ 26 | 27 | async def _check_single_mirror(mirror: MirrorSource) -> tuple[MirrorSource, float]: 28 | """检查单个镜像源的可用性。""" 29 | try: 30 | parsed_url = urlparse(mirror.url) 31 | host = parsed_url.hostname 32 | port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80) 33 | 34 | start_time = asyncio.get_event_loop().time() 35 | 36 | _, _ = await asyncio.wait_for( 37 | asyncio.open_connection(host, port), timeout=timeout 38 | ) 39 | 40 | elapsed = asyncio.get_event_loop().time() - start_time 41 | return mirror, round(elapsed, 2) 42 | 43 | except Exception as e: 44 | logger.debug(f"镜像源 {mirror.name} 连接失败: {e!s}") 45 | return mirror, float("inf") 46 | 47 | if plugin_config.htmlrender_download_host: 48 | mirrors = [ 49 | *MIRRORS, 50 | MirrorSource("自定义镜像", plugin_config.htmlrender_download_host, 0), 51 | ] 52 | else: 53 | mirrors = MIRRORS 54 | tasks = [_check_single_mirror(mirror) for mirror in mirrors] 55 | results: list[tuple[MirrorSource, float]] = await asyncio.gather(*tasks) 56 | 57 | available_mirrors = [(m, t) for m, t in results if t != float("inf")] 58 | if not available_mirrors: 59 | return None 60 | 61 | logger.debug(f"available_mirrors: {available_mirrors}") 62 | return min(available_mirrors, key=lambda x: (x[1], -x[0].priority))[0] 63 | 64 | 65 | @asynccontextmanager 66 | async def download_context() -> AsyncIterator[None]: 67 | """为下载设置上下文管理器,动态配置下载源和代理设置。 68 | 69 | 该上下文管理器会设置合适的下载源和代理,并确保在退出时恢复原有环境变量。 70 | 71 | Yields: 72 | None: 占位符。 73 | """ 74 | had_original = "PLAYWRIGHT_DOWNLOAD_HOST" in os.environ 75 | original_host = os.environ.get("PLAYWRIGHT_DOWNLOAD_HOST") 76 | os.environ["PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT"] = "300000" 77 | 78 | if plugin_config.htmlrender_download_proxy: 79 | proxy = plugin_config.htmlrender_download_proxy 80 | if proxy.startswith("http://") and not os.environ.get("HTTP_PROXY"): 81 | logger.info(f"Using http Proxy: {proxy}") 82 | os.environ["HTTP_PROXY"] = proxy 83 | elif proxy.startswith("https://") and not os.environ.get("HTTPS_PROXY"): 84 | logger.info(f"Using https Proxy: {proxy}") 85 | os.environ["HTTPS_PROXY"] = proxy 86 | 87 | try: 88 | best_mirror = await check_mirror_connectivity() 89 | if best_mirror is not None: 90 | logger.opt(colors=True).info( 91 | f"Using Mirror source: {best_mirror.name} " 92 | f"{best_mirror.url}" 93 | ) 94 | os.environ["PLAYWRIGHT_DOWNLOAD_HOST"] = best_mirror.url 95 | else: 96 | logger.info("Mirror source not available, using default") 97 | 98 | yield 99 | 100 | finally: 101 | if had_original and original_host is not None: 102 | os.environ["PLAYWRIGHT_DOWNLOAD_HOST"] = original_host 103 | elif "PLAYWRIGHT_DOWNLOAD_HOST" in os.environ: 104 | del os.environ["PLAYWRIGHT_DOWNLOAD_HOST"] 105 | 106 | if "HTTP_PROXY" in os.environ: 107 | del os.environ["HTTP_PROXY"] 108 | if "HTTPS_PROXY" in os.environ: 109 | del os.environ["HTTPS_PROXY"] 110 | 111 | 112 | async def read_stream( 113 | stream: Optional[asyncio.StreamReader], 114 | callback: Optional[Callable[[str], Awaitable[None]]] = None, 115 | ) -> str: 116 | """读取流数据并处理每一行。 117 | 118 | 对于包含进度的行,会触发进度回调。 119 | 120 | Args: 121 | stream (Optional[asyncio.StreamReader]): 用于读取数据的 asyncio.StreamReader 122 | 对象。 123 | callback (Optional[Callable[[str], Awaitable[None]]]): 可选的回调函数,接收每一 124 | 行的内容并返回一个 awaitable。 125 | 126 | Returns: 127 | str: 读取的所有内容。 128 | """ 129 | if stream is None: 130 | return "" 131 | 132 | output = [] # 存储读取到的文本内容 133 | 134 | while True: 135 | try: 136 | line_data = await stream.readline() 137 | if not line_data: 138 | break 139 | 140 | line = line_data.decode().strip() 141 | 142 | if callback: 143 | try: 144 | await callback(line) 145 | except UnicodeEncodeError: 146 | safe_text = "".join(c if ord(c) < 128 else "?" for c in line) 147 | await callback(safe_text) 148 | 149 | if line: 150 | output.append(line) 151 | 152 | except asyncio.IncompleteReadError: 153 | break 154 | except Exception as e: 155 | logger.opt(exception=True).error(f"Error reading stream: {e!s}") 156 | break 157 | 158 | return "\n".join(output) 159 | 160 | 161 | async def execute_install_command(timeout: int) -> tuple[bool, str]: 162 | """执行浏览器安装命令。 163 | 164 | Args: 165 | timeout (int): 安装过程中等待的最大秒数。 166 | 167 | Returns: 168 | tuple[bool, str]: 安装是否成功以及相关消息。 169 | """ 170 | try: 171 | logger.debug("Starting playwright install process...") 172 | install_signal_handler() 173 | 174 | process = await create_process( 175 | sys.executable, 176 | "-m", 177 | "playwright", 178 | "install", 179 | "--with-deps", 180 | plugin_config.htmlrender_browser, 181 | stdout=asyncio.subprocess.PIPE, 182 | stderr=asyncio.subprocess.PIPE, 183 | ) 184 | 185 | async def stdout_callback(line: str) -> None: 186 | line_stripped = line.strip() 187 | if ( 188 | not line_stripped.startswith("Progress:") 189 | and "|" not in line_stripped 190 | and "%" not in line_stripped 191 | and line_stripped 192 | ): 193 | logger.info(line_stripped) 194 | 195 | async def stderr_callback(line: str) -> None: 196 | line_stripped = line.strip() 197 | if line_stripped: 198 | logger.warning(f"Install error: {line_stripped}") 199 | 200 | stdout_task = asyncio.create_task(read_stream(process.stdout, stdout_callback)) 201 | stderr_task = asyncio.create_task(read_stream(process.stderr, stderr_callback)) 202 | 203 | try: 204 | await asyncio.wait_for( 205 | asyncio.gather(stdout_task, stderr_task), timeout=timeout 206 | ) 207 | except asyncio.TimeoutError: 208 | logger.error(f"Timed out ({timeout}s)") 209 | await terminate_process(process) 210 | return False, f"Timed out ({timeout}s)" 211 | 212 | await process.wait() 213 | return_code = process.returncode 214 | 215 | if return_code != 0: 216 | return False, f"Exited with code {return_code}" 217 | 218 | return True, "Installation completed" 219 | 220 | except Exception as e: 221 | logger.error(f"An error occurred during installation: {e!s}") 222 | return False, f"An error occurred during installation: {e!s}" 223 | 224 | 225 | async def install_browser(timeout: int = 300) -> bool: 226 | """安装用于 Playwright 的浏览器。 227 | 228 | Args: 229 | timeout (int): 安装过程中等待的最大秒数。 230 | 231 | Returns: 232 | bool: 是否安装成功。 233 | """ 234 | async with download_context(): 235 | logger.opt(colors=True).info( 236 | f"Checking {plugin_config.htmlrender_browser} installation..." 237 | ) 238 | installed, message = await execute_install_command(timeout) 239 | if installed: 240 | logger.info("Installation succeeded") 241 | return True 242 | else: 243 | logger.warning("Installation failed, retrying with official mirror...") 244 | os.environ.pop("PLAYWRIGHT_DOWNLOAD_HOST", None) 245 | installed, message = await execute_install_command(timeout) 246 | if installed: 247 | logger.info("Installation succeeded") 248 | return True 249 | else: 250 | logger.error(f"Installation failed with: {message}") 251 | return False 252 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/browser.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncIterator 2 | from contextlib import AsyncExitStack, asynccontextmanager 3 | from typing import Optional 4 | 5 | from nonebot.log import logger 6 | from playwright.async_api import ( 7 | Browser, 8 | BrowserType, 9 | Page, 10 | Playwright, 11 | async_playwright, 12 | ) 13 | from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed 14 | 15 | from nonebot_plugin_htmlrender.config import plugin_config 16 | from nonebot_plugin_htmlrender.install import install_browser 17 | from nonebot_plugin_htmlrender.utils import ( 18 | _prepare_playwright_env_vars, 19 | clean_playwright_cache, 20 | proxy_settings, 21 | suppress_and_log, 22 | with_lock, 23 | ) 24 | 25 | _browser: Optional[Browser] = None 26 | _playwright: Optional[Playwright] = None 27 | 28 | 29 | async def _launch(browser_type: str, **kwargs) -> Browser: 30 | """ 31 | 启动浏览器实例。 32 | 33 | Args: 34 | browser_type (str): 浏览器类型。 35 | **kwargs: 传递给`playwright.launch`的关键字参数。 36 | 37 | Returns: 38 | Browser: 启动的浏览器实例。 39 | """ 40 | _browser_cls: BrowserType = getattr(_playwright, browser_type) 41 | logger.opt(colors=True).debug( 42 | f"{browser_type.capitalize()} launching with kwargs: {kwargs}" 43 | ) 44 | logger.opt(colors=True).debug( 45 | f"Looking for Browser in path: {_browser_cls.executable_path}" 46 | ) 47 | return await _browser_cls.launch(**kwargs) 48 | 49 | 50 | @asynccontextmanager 51 | async def get_new_page(device_scale_factor: float = 2, **kwargs) -> AsyncIterator[Page]: 52 | """ 53 | 获取一个新的页面的上下文管理器, 这里的 page 默认使用设备缩放因子为 2。 54 | 55 | Args: 56 | device_scale_factor (float): 设备缩放因子。 57 | **kwargs: 传递给`browser.new_context`的关键字参数。 58 | 59 | Yields: 60 | Page: 页面对象。 61 | """ 62 | ctx = await get_browser() 63 | page = await ctx.new_page(device_scale_factor=device_scale_factor, **kwargs) 64 | async with page: 65 | yield page 66 | 67 | 68 | @with_lock 69 | async def get_browser(**kwargs) -> Browser: 70 | """ 71 | 获取浏览器实例。 72 | 73 | Args: 74 | **kwargs: 传递给`playwright.launch`的关键字参数。 75 | 76 | Returns: 77 | Browser: 浏览器实例。 78 | """ 79 | if _browser and _browser.is_connected(): 80 | return _browser 81 | 82 | return await startup_htmlrender(**kwargs) 83 | 84 | 85 | async def _connect_via_cdp(**kwargs) -> Browser: 86 | """ 87 | 通过 CDP 连接 Chromium 浏览器。 88 | 89 | Args: 90 | **kwargs: 传递给`chromium.connect_over_cdp`的关键字参数。 91 | 92 | Returns: 93 | Browser: 通过 CDP 连接的浏览器实例。 94 | 95 | Raises: 96 | RuntimeError: 如果 Playwright 未初始化。 97 | """ 98 | kwargs["endpoint_url"] = plugin_config.htmlrender_connect_over_cdp 99 | logger.info( 100 | f"Connecting to Chromium via CDP ({plugin_config.htmlrender_connect_over_cdp})" 101 | ) 102 | if _playwright is not None: 103 | return await _playwright.chromium.connect_over_cdp(**kwargs) 104 | else: 105 | raise RuntimeError("Playwright is not initialized") 106 | 107 | 108 | async def _connect(browser_type: str, **kwargs) -> Browser: 109 | """ 110 | 通过 Playwright 协议连接浏览器。 111 | 112 | Args: 113 | browser_type (str): 浏览器类型。 114 | **kwargs: 传递给`playwright.connect`的关键字参数。 115 | 116 | Returns: 117 | Browser: 启动的浏览器实例。 118 | 119 | Raises: 120 | RuntimeError: 如果 Playwright 未初始化。 121 | """ 122 | _browser_cls: BrowserType = getattr(_playwright, browser_type) 123 | kwargs["ws_endpoint"] = plugin_config.htmlrender_connect 124 | logger.info( 125 | f"Connecting to {browser_type.capitalize()} via " 126 | f"WebSocket endpoint: {plugin_config.htmlrender_connect}" 127 | ) 128 | if _playwright is not None: 129 | return await _browser_cls.connect(**kwargs) 130 | else: 131 | raise RuntimeError("Playwright is not initialized") 132 | 133 | 134 | @retry( 135 | retry=retry_if_exception_type(RuntimeError), 136 | stop=stop_after_attempt(4), 137 | wait=wait_fixed(1), 138 | reraise=True, 139 | before_sleep=lambda retry_state: logger.warning( 140 | f"Attempt {retry_state.attempt_number} failed, retrying..." 141 | ), 142 | ) 143 | async def _check_env_with_install_retry(**kwargs): 144 | try: 145 | return await check_playwright_env(**kwargs) 146 | except RuntimeError: 147 | if plugin_config.htmlrender_ci_mode: 148 | raise 149 | try: 150 | await install_browser() 151 | except Exception as e: 152 | logger.error(f"Browser installation failed: {e!s}") 153 | raise RuntimeError(f"install_browser failed: {e}") from e 154 | raise 155 | 156 | 157 | @with_lock 158 | async def startup_htmlrender(**kwargs) -> Browser: 159 | """ 160 | 启动 Playwright 浏览器实例。 161 | 162 | Args: 163 | **kwargs: 传递给`playwright.launch`的关键字参数。 164 | 165 | Returns: 166 | Browser: 启动的浏览器实例。 167 | """ 168 | global _browser, _playwright 169 | 170 | await shutdown_htmlrender() 171 | 172 | if not plugin_config.htmlrender_ci_mode: 173 | clean_playwright_cache() 174 | _prepare_playwright_env_vars() 175 | 176 | _playwright = await async_playwright().start() 177 | logger.debug("Playwright started") 178 | 179 | if ( 180 | plugin_config.htmlrender_browser == "chromium" 181 | and plugin_config.htmlrender_connect_over_cdp 182 | ): 183 | _browser = await _connect_via_cdp(**kwargs) 184 | elif plugin_config.htmlrender_connect: 185 | _browser = await _connect(plugin_config.htmlrender_browser, **kwargs) 186 | else: 187 | if plugin_config.htmlrender_browser_channel: 188 | kwargs["channel"] = plugin_config.htmlrender_browser_channel 189 | 190 | if plugin_config.htmlrender_proxy_host: 191 | kwargs["proxy"] = proxy_settings(plugin_config.htmlrender_proxy_host) 192 | 193 | if plugin_config.htmlrender_browser_args: 194 | kwargs["args"] = plugin_config.htmlrender_browser_args.split() 195 | 196 | if plugin_config.htmlrender_browser_executable_path: 197 | kwargs["executable_path"] = plugin_config.htmlrender_browser_executable_path 198 | try: 199 | _browser = await _launch(plugin_config.htmlrender_browser, **kwargs) 200 | except Exception as e: 201 | raise RuntimeError( 202 | f"Failed to launch browser with executable path" 203 | f" '{plugin_config.htmlrender_browser_executable_path}': {e}" 204 | ) from e 205 | else: 206 | _browser = await _check_env_with_install_retry(**kwargs) 207 | 208 | return _browser 209 | 210 | 211 | async def shutdown_htmlrender() -> None: 212 | is_remote = bool( 213 | plugin_config.htmlrender_connect or plugin_config.htmlrender_connect_over_cdp 214 | ) 215 | async with AsyncExitStack() as stack: 216 | await _schedule_browser_shutdown(stack, is_remote=is_remote) 217 | await _schedule_playwright_shutdown(stack) 218 | _clear_globals() 219 | 220 | 221 | async def _schedule_browser_shutdown(stack: AsyncExitStack, *, is_remote: bool) -> None: 222 | global _browser 223 | if not _browser: 224 | return 225 | 226 | should_close = (not is_remote) and plugin_config.htmlrender_shutdown_browser_on_exit 227 | if not should_close: 228 | logger.info( 229 | "Skipping browser shutdown due to configuration or remote connection." 230 | ) 231 | return 232 | 233 | browser = _browser 234 | if not browser.is_connected(): 235 | logger.info("Browser was already disconnected.") 236 | return 237 | 238 | logger.debug("Disconnecting browser...") 239 | 240 | async def _close_browser(): 241 | with suppress_and_log(): 242 | await browser.close() 243 | logger.info("Disconnected browser.") 244 | 245 | stack.push_async_callback(_close_browser) 246 | 247 | 248 | async def _schedule_playwright_shutdown(stack: AsyncExitStack) -> None: 249 | global _playwright 250 | if not _playwright: 251 | return 252 | 253 | pw = _playwright 254 | logger.debug("Stopping Playwright...") 255 | 256 | async def _stop_pw(): 257 | with suppress_and_log(): 258 | await pw.stop() 259 | logger.info("Playwright stopped.") 260 | 261 | stack.push_async_callback(_stop_pw) 262 | 263 | 264 | def _clear_globals() -> None: 265 | global _browser, _playwright 266 | _browser = None 267 | _playwright = None 268 | 269 | 270 | async def check_playwright_env(**kwargs) -> Browser: 271 | """ 272 | 检查Playwright环境,复用_launch方法避免逻辑重复。 273 | 274 | Args: 275 | **kwargs: 传递给`playwright.launch`的关键字参数。 276 | 277 | Raises: 278 | RuntimeError: 如果Playwright环境设置不正确。 279 | """ 280 | logger.info("Checking Playwright environment...") 281 | global _browser, _playwright 282 | 283 | try: 284 | _playwright = await async_playwright().start() 285 | _browser = await _launch(plugin_config.htmlrender_browser, **kwargs) 286 | logger.success("Playwright environment is set up correctly.") 287 | return _browser 288 | 289 | except Exception as e: 290 | await shutdown_htmlrender() 291 | 292 | raise RuntimeError( 293 | "Playwright environment is not set up correctly. " 294 | "Refer to https://playwright.dev/python/docs/intro#system-requirements" 295 | ) from e 296 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/data_source.py: -------------------------------------------------------------------------------- 1 | from os import getcwd 2 | from pathlib import Path 3 | from typing import Any, Literal, Optional, Union 4 | 5 | import aiofiles 6 | import jinja2 7 | import markdown 8 | from nonebot.log import logger 9 | 10 | from nonebot_plugin_htmlrender.browser import get_new_page 11 | 12 | TEMPLATES_PATH = str(Path(__file__).parent / "templates") 13 | 14 | env = jinja2.Environment( 15 | extensions=["jinja2.ext.loopcontrols"], 16 | loader=jinja2.FileSystemLoader(TEMPLATES_PATH), 17 | enable_async=True, 18 | ) 19 | 20 | 21 | async def text_to_pic( 22 | text: str, 23 | css_path: str = "", 24 | width: int = 500, 25 | type: Literal["jpeg", "png"] = "png", 26 | quality: Union[int, None] = None, 27 | device_scale_factor: float = 2, 28 | screenshot_timeout: Optional[float] = 30_000, 29 | ) -> bytes: 30 | """多行文本转图片 31 | 32 | Args: 33 | screenshot_timeout (float, optional): 截图超时时间,默认30000ms 34 | text (str): 纯文本, 可多行 35 | css_path (str, optional): css文件 36 | width (int, optional): 图片宽度,默认为 500 37 | type (Literal["jpeg", "png"]): 图片类型, 默认 png 38 | quality (int, optional): 图片质量 0-100 当为`png`时无效 39 | device_scale_factor: 缩放比例,类型为float,值越大越清晰 40 | 41 | Returns: 42 | bytes: 图片, 可直接发送 43 | """ 44 | template = env.get_template("text.html") 45 | 46 | return await html_to_pic( 47 | template_path=f"file://{css_path or TEMPLATES_PATH}", 48 | html=await template.render_async( 49 | text=text, 50 | css=await read_file(css_path) if css_path else await read_tpl("text.css"), 51 | ), 52 | viewport={"width": width, "height": 10}, 53 | type=type, 54 | quality=quality, 55 | device_scale_factor=device_scale_factor, 56 | screenshot_timeout=screenshot_timeout, 57 | ) 58 | 59 | 60 | async def md_to_pic( 61 | md: str = "", 62 | md_path: str = "", 63 | css_path: str = "", 64 | width: int = 500, 65 | type: Literal["jpeg", "png"] = "png", 66 | quality: Union[int, None] = None, 67 | device_scale_factor: float = 2, 68 | screenshot_timeout: Optional[float] = 30_000, 69 | ) -> bytes: 70 | """markdown 转 图片 71 | 72 | Args: 73 | screenshot_timeout (float, optional): 截图超时时间,默认30000ms 74 | md (str, optional): markdown 格式文本 75 | md_path (str, optional): markdown 文件路径 76 | css_path (str, optional): css文件路径. Defaults to None. 77 | width (int, optional): 图片宽度,默认为 500 78 | type (Literal["jpeg", "png"]): 图片类型, 默认 png 79 | quality (int, optional): 图片质量 0-100 当为`png`时无效 80 | device_scale_factor: 缩放比例,类型为float,值越大越清晰 81 | 82 | Returns: 83 | bytes: 图片, 可直接发送 84 | """ 85 | template = env.get_template("markdown.html") 86 | if not md: 87 | if md_path: 88 | md = await read_file(md_path) 89 | else: 90 | raise Exception("md or md_path must be provided") 91 | logger.debug(md) 92 | md = markdown.markdown( 93 | md, 94 | extensions=[ 95 | "pymdownx.tasklist", 96 | "tables", 97 | "fenced_code", 98 | "codehilite", 99 | "mdx_math", 100 | "pymdownx.tilde", 101 | ], 102 | extension_configs={"mdx_math": {"enable_dollar_delimiter": True}}, 103 | ) 104 | 105 | logger.debug(md) 106 | extra = "" 107 | if "math/tex" in md: 108 | katex_css = await read_tpl("katex/katex.min.b64_fonts.css") 109 | katex_js = await read_tpl("katex/katex.min.js") 110 | mhchem_js = await read_tpl("katex/mhchem.min.js") 111 | mathtex_js = await read_tpl("katex/mathtex-script-type.min.js") 112 | extra = ( 113 | f'' 114 | f"" 115 | f"" 116 | f"" 117 | ) 118 | 119 | if css_path: 120 | css = await read_file(css_path) 121 | else: 122 | css = await read_tpl("github-markdown-light.css") + await read_tpl( 123 | "pygments-default.css", 124 | ) 125 | 126 | return await html_to_pic( 127 | template_path=f"file://{css_path or TEMPLATES_PATH}", 128 | html=await template.render_async(md=md, css=css, extra=extra), 129 | viewport={"width": width, "height": 10}, 130 | type=type, 131 | quality=quality, 132 | device_scale_factor=device_scale_factor, 133 | screenshot_timeout=screenshot_timeout, 134 | ) 135 | 136 | 137 | # async def read_md(md_path: str) -> str: 138 | # async with aiofiles.open(str(Path(md_path).resolve()), mode="r") as f: 139 | # md = await f.read() 140 | # return markdown.markdown(md) 141 | 142 | 143 | async def read_file(path: str) -> str: 144 | async with aiofiles.open(path, encoding="UTF8") as f: 145 | return await f.read() 146 | 147 | 148 | async def read_tpl(path: str) -> str: 149 | return await read_file(f"{TEMPLATES_PATH}/{path}") 150 | 151 | 152 | async def template_to_html( 153 | template_path: str, 154 | template_name: str, 155 | filters: Optional[dict[str, Any]] = None, 156 | **kwargs, 157 | ) -> str: 158 | """使用jinja2模板引擎通过html生成图片 159 | 160 | Args: 161 | template_path (str): 模板路径 162 | template_name (str): 模板名 163 | filters (Optional[Dict[str, Any]]): 自定义过滤器 164 | **kwargs: 模板内容 165 | Returns: 166 | str: html 167 | """ 168 | 169 | template_env = jinja2.Environment( 170 | loader=jinja2.FileSystemLoader(template_path), 171 | enable_async=True, 172 | ) 173 | 174 | if filters: 175 | for filter_name, filter_func in filters.items(): 176 | template_env.filters[filter_name] = filter_func 177 | logger.debug(f"Custom filter loaded: {filter_name}") 178 | 179 | template = template_env.get_template(template_name) 180 | 181 | return await template.render_async(**kwargs) 182 | 183 | 184 | async def html_to_pic( 185 | html: str, 186 | wait: int = 0, 187 | template_path: str = f"file://{getcwd()}", 188 | type: Literal["jpeg", "png"] = "png", 189 | quality: Union[int, None] = None, 190 | device_scale_factor: float = 2, 191 | screenshot_timeout: Optional[float] = 30_000, 192 | full_page: Optional[bool] = True, 193 | **kwargs, 194 | ) -> bytes: 195 | """html转图片 196 | 197 | Args: 198 | screenshot_timeout (float, optional): 截图超时时间,默认30000ms 199 | html (str): html文本 200 | wait (int, optional): 等待时间. Defaults to 0. 201 | template_path (str, optional): 模板路径 如 "file:///path/to/template/" 202 | type (Literal["jpeg", "png"]): 图片类型, 默认 png 203 | quality (int, optional): 图片质量 0-100 当为`png`时无效 204 | device_scale_factor: 缩放比例,类型为float,值越大越清晰 205 | **kwargs: 传入 page 的参数 206 | 207 | Returns: 208 | bytes: 图片, 可直接发送 209 | """ 210 | # logger.debug(f"html:\n{html}") 211 | if "file:" not in template_path: 212 | raise Exception("template_path should be file:///path/to/template") 213 | async with get_new_page(device_scale_factor, **kwargs) as page: 214 | page.on("console", lambda msg: logger.debug(f"[Browser Console]: {msg.text}")) 215 | await page.goto(template_path) 216 | await page.set_content(html, wait_until="networkidle") 217 | await page.wait_for_timeout(wait) 218 | return await page.screenshot( 219 | full_page=full_page, 220 | type=type, 221 | quality=quality, 222 | timeout=screenshot_timeout, 223 | ) 224 | 225 | 226 | async def template_to_pic( 227 | template_path: str, 228 | template_name: str, 229 | templates: dict[Any, Any], 230 | filters: Optional[dict[str, Any]] = None, 231 | pages: Optional[dict[Any, Any]] = None, 232 | wait: int = 0, 233 | type: Literal["jpeg", "png"] = "png", 234 | quality: Union[int, None] = None, 235 | device_scale_factor: float = 2, 236 | screenshot_timeout: Optional[float] = 30_000, 237 | ) -> bytes: 238 | """使用jinja2模板引擎通过html生成图片 239 | 240 | Args: 241 | screenshot_timeout (float, optional): 截图超时时间,默认30000ms 242 | template_path (str): 模板路径 243 | template_name (str): 模板名 244 | templates (Dict[Any, Any]): 模板内参数 如: {"name": "abc"} 245 | filters (Optional[Dict[str, Any]]): 自定义过滤器 246 | pages (Optional[Dict[Any, Any]]): 网页参数 Defaults to 247 | {"base_url": f"file://{getcwd()}", "viewport": {"width": 500, "height": 10}} 248 | wait (int, optional): 网页载入等待时间. Defaults to 0. 249 | type (Literal["jpeg", "png"]): 图片类型, 默认 png 250 | quality (int, optional): 图片质量 0-100 当为`png`时无效 251 | device_scale_factor: 缩放比例,类型为float,值越大越清晰 252 | Returns: 253 | bytes: 图片 可直接发送 254 | """ 255 | if pages is None: 256 | pages = { 257 | "viewport": {"width": 500, "height": 10}, 258 | "base_url": f"file://{getcwd()}", 259 | } 260 | 261 | template_env = jinja2.Environment( 262 | loader=jinja2.FileSystemLoader(template_path), 263 | enable_async=True, 264 | ) 265 | 266 | if filters: 267 | for filter_name, filter_func in filters.items(): 268 | template_env.filters[filter_name] = filter_func 269 | logger.debug(f"Custom filter loaded: {filter_name}") 270 | 271 | template = template_env.get_template(template_name) 272 | 273 | return await html_to_pic( 274 | template_path=f"file://{template_path}", 275 | html=await template.render_async(**templates), 276 | wait=wait, 277 | type=type, 278 | quality=quality, 279 | device_scale_factor=device_scale_factor, 280 | screenshot_timeout=screenshot_timeout, 281 | **pages, 282 | ) 283 | 284 | 285 | async def capture_element( 286 | url: str, 287 | element: str, 288 | page_kwargs: Optional[dict] = None, 289 | goto_kwargs: Optional[dict] = None, 290 | screenshot_kwargs: Optional[dict] = None, 291 | ) -> bytes: 292 | """捕获网页中指定元素的截图, 通过CSS选择器或XPath表达式指定元素。 293 | 294 | Args: 295 | url: 目标网页URL 296 | element: CSS选择器或XPath表达式 297 | page_kwargs: 传递给get_new_page的参数 298 | goto_kwargs: 传递给page.goto方法的额外参数 299 | screenshot_kwargs: 传递给screenshot方法的额外参数 300 | 301 | Returns: 302 | bytes: 元素截图数据 303 | """ 304 | page_kwargs = page_kwargs or {} 305 | goto_kwargs = goto_kwargs or {} 306 | screenshot_kwargs = screenshot_kwargs or {} 307 | 308 | async with get_new_page(**page_kwargs) as page: 309 | page.on( 310 | "console", 311 | lambda msg: logger.opt(colors=True).debug( 312 | f"[Browser Console] {msg.text}" 313 | ), 314 | ) 315 | await page.goto(url, **goto_kwargs) 316 | return await page.locator(element).screenshot(**screenshot_kwargs) 317 | -------------------------------------------------------------------------------- /tests/test_browser.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator 2 | from pathlib import Path 3 | from unittest.mock import AsyncMock 4 | 5 | from playwright.async_api import Browser, Page 6 | import pytest 7 | from pytest_mock import MockerFixture 8 | 9 | 10 | @pytest.fixture 11 | def mock_browser(mocker: MockerFixture) -> Browser: 12 | """模拟的浏览器实例 fixture""" 13 | browser = mocker.AsyncMock(spec=Browser) 14 | browser.is_connected.return_value = True 15 | browser.close = mocker.AsyncMock() 16 | return browser 17 | 18 | 19 | @pytest.fixture 20 | def mock_page(mocker: MockerFixture) -> AsyncMock: 21 | """模拟的页面实例 fixture""" 22 | mock = mocker.AsyncMock(spec=Page) 23 | mock.close = mocker.AsyncMock() 24 | return mock 25 | 26 | 27 | @pytest.fixture 28 | async def mock_browser_context( 29 | mocker: MockerFixture, mock_browser: Browser 30 | ) -> AsyncGenerator[None, None]: 31 | """模拟浏览器上下文的 fixture""" 32 | mocker.patch("nonebot_plugin_htmlrender.browser._browser", mock_browser) 33 | yield 34 | mocker.patch("nonebot_plugin_htmlrender.browser._browser", None) 35 | 36 | 37 | @pytest.fixture 38 | def browser_config() -> dict[str, str]: 39 | """浏览器配置的 fixture""" 40 | return { 41 | "browser": "chromium", 42 | "cdp": "ws://localhost:9222", 43 | "pwp": "ws://localhost:3000/chromium/playwright", 44 | } 45 | 46 | 47 | @pytest.mark.asyncio 48 | @pytest.mark.parametrize( 49 | "exception", 50 | [Exception("Test error"), ValueError("Test value error")], 51 | ids=["exception", "value_error"], 52 | ) 53 | async def test_suppress_and_log(mocker: MockerFixture, exception: Exception) -> None: 54 | """测试 _suppress_and_log 异常抑制和日志记录""" 55 | from nonebot_plugin_htmlrender.utils import suppress_and_log 56 | 57 | mock_logger = mocker.patch("nonebot_plugin_htmlrender.utils.logger") 58 | 59 | with suppress_and_log(): 60 | raise exception 61 | 62 | mock_logger.opt.assert_called_once_with(exception=exception) 63 | mock_logger.opt().warning.assert_called_once() 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_launch(mocker: MockerFixture, browser_config: dict[str, str]) -> None: 68 | """测试浏览器启动""" 69 | from nonebot_plugin_htmlrender.browser import _launch 70 | 71 | mock_browser_type = mocker.AsyncMock() 72 | mock_playwright = mocker.MagicMock() 73 | setattr(mock_playwright, "chromium", mock_browser_type) 74 | 75 | mocker.patch("nonebot_plugin_htmlrender.browser._playwright", mock_playwright) 76 | await _launch(browser_config["browser"]) 77 | 78 | mock_browser_type.launch.assert_called_once() 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_init_browser_success( 83 | mocker: MockerFixture, mock_browser: Browser 84 | ) -> None: 85 | """测试浏览器初始化成功""" 86 | from nonebot_plugin_htmlrender.browser import startup_htmlrender 87 | 88 | browser = await startup_htmlrender() 89 | assert isinstance(browser, Browser) 90 | assert browser.is_connected() 91 | assert browser.browser_type.name == "chromium" 92 | await browser.close() 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_get_new_page( 97 | mocker: MockerFixture, 98 | mock_browser: Browser, 99 | mock_page: AsyncMock, 100 | ) -> None: 101 | """测试获取新页面""" 102 | from nonebot_plugin_htmlrender.browser import get_new_page 103 | 104 | close_mock = mocker.AsyncMock() 105 | mock_page.close = close_mock 106 | mock_page.__aenter__.return_value = mock_page 107 | 108 | async def _aexit(*args): 109 | await mock_page.close() 110 | 111 | mock_page.__aexit__ = mocker.AsyncMock(side_effect=_aexit) 112 | 113 | new_page_mock = mocker.AsyncMock(return_value=mock_page) 114 | mocker.patch.object(mock_browser, "new_page", new_page_mock) 115 | 116 | mocker.patch( 117 | "nonebot_plugin_htmlrender.browser.get_browser", 118 | return_value=mock_browser, 119 | ) 120 | 121 | async with get_new_page() as page: 122 | assert page == mock_page 123 | 124 | assert close_mock.call_count == 1 125 | 126 | 127 | @pytest.mark.asyncio 128 | async def test_get_browser_connected( 129 | mock_browser: Browser, mock_browser_context: None 130 | ) -> None: 131 | """测试获取已连接的浏览器""" 132 | from nonebot_plugin_htmlrender.browser import get_browser 133 | 134 | browser = await get_browser() 135 | assert browser == mock_browser 136 | 137 | 138 | @pytest.mark.asyncio 139 | async def test_shutdown_browser( 140 | mock_browser: Browser, 141 | mock_browser_context: None, 142 | mocker: MockerFixture, 143 | ) -> None: 144 | """测试关闭浏览器""" 145 | from nonebot_plugin_htmlrender.browser import shutdown_htmlrender 146 | 147 | close_mock = mocker.AsyncMock() 148 | mock_browser.close = close_mock 149 | 150 | await shutdown_htmlrender() 151 | assert close_mock.call_count == 1 152 | 153 | 154 | @pytest.mark.asyncio 155 | async def test_connect_via_cdp( 156 | mocker: MockerFixture, mock_browser: Browser, browser_config: dict[str, str] 157 | ) -> None: 158 | """测试通过CDP连接浏览器""" 159 | from nonebot_plugin_htmlrender.browser import startup_htmlrender 160 | 161 | mocker.patch( 162 | "nonebot_plugin_htmlrender.browser._connect_via_cdp", return_value=mock_browser 163 | ) 164 | mocker.patch( 165 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_browser", 166 | browser_config["browser"], 167 | ) 168 | mocker.patch( 169 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_connect_over_cdp", 170 | browser_config["cdp"], 171 | ) 172 | 173 | browser = await startup_htmlrender() 174 | assert browser == mock_browser 175 | 176 | 177 | @pytest.mark.asyncio 178 | async def test_connect( 179 | mocker: MockerFixture, mock_browser: Browser, browser_config: dict[str, str] 180 | ) -> None: 181 | """测试通过Playwright协议连接浏览器""" 182 | from nonebot_plugin_htmlrender.browser import startup_htmlrender 183 | 184 | mocker.patch( 185 | "nonebot_plugin_htmlrender.browser._connect", return_value=mock_browser 186 | ) 187 | mocker.patch( 188 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_browser", 189 | browser_config["browser"], 190 | ) 191 | mocker.patch( 192 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_connect", 193 | browser_config["pwp"], 194 | ) 195 | 196 | browser = await startup_htmlrender() 197 | assert browser == mock_browser 198 | 199 | 200 | @pytest.mark.parametrize( 201 | ("proxy_url", "expected"), 202 | [ 203 | ( 204 | "http://user:pass@proxy.com:8080", 205 | {"server": "http://proxy.com:8080", "username": "user", "password": "pass"}, 206 | ), 207 | ("http://proxy.com:8080", {"server": "http://proxy.com:8080"}), 208 | ], 209 | ids=["with_auth", "without_auth"], 210 | ) 211 | def test_enhance_proxy_settings(proxy_url: str, expected: dict[str, str]) -> None: 212 | """测试代理设置基本功能""" 213 | from nonebot_plugin_htmlrender.utils import proxy_settings 214 | 215 | result = proxy_settings(proxy_url) 216 | assert result == expected 217 | 218 | 219 | def test_enhance_proxy_settings_with_bypass(mocker: MockerFixture) -> None: 220 | """测试代理设置bypass功能""" 221 | from nonebot_plugin_htmlrender.utils import proxy_settings 222 | 223 | mocker.patch( 224 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_proxy_host_bypass", 225 | "localhost", 226 | ) 227 | 228 | result = proxy_settings("http://proxy.com:8080") 229 | assert result == {"server": "http://proxy.com:8080", "bypass": "localhost"} 230 | 231 | 232 | def test_enhance_proxy_settings_none() -> None: 233 | """测试空代理设置""" 234 | from nonebot_plugin_htmlrender.utils import proxy_settings 235 | 236 | result = proxy_settings(None) 237 | assert result is None 238 | 239 | 240 | @pytest.mark.asyncio 241 | async def test_start_browser_with_cdp( 242 | mocker: MockerFixture, browser_config: dict[str, str] 243 | ) -> None: 244 | """测试使用CDP启动浏览器""" 245 | from nonebot_plugin_htmlrender.browser import startup_htmlrender 246 | 247 | mock_cdp = mocker.patch( 248 | "nonebot_plugin_htmlrender.browser._connect_via_cdp", 249 | return_value=mocker.MagicMock(spec=Browser), 250 | ) 251 | mocker.patch("playwright.async_api.async_playwright") 252 | mocker.patch( 253 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_browser", 254 | browser_config["browser"], 255 | ) 256 | mocker.patch( 257 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_connect_over_cdp", 258 | browser_config["cdp"], 259 | ) 260 | 261 | await startup_htmlrender() 262 | mock_cdp.assert_called_once() 263 | 264 | 265 | @pytest.mark.asyncio 266 | async def test_start_browser_with_config(mocker: MockerFixture) -> None: 267 | """测试带配置启动浏览器""" 268 | from nonebot_plugin_htmlrender.browser import startup_htmlrender 269 | 270 | mock_launch = mocker.patch( 271 | "nonebot_plugin_htmlrender.browser._launch", 272 | return_value=mocker.MagicMock(spec=Browser), 273 | ) 274 | mocker.patch("playwright.async_api.async_playwright") 275 | mocker.patch( 276 | "nonebot_plugin_htmlrender.browser.plugin_config.htmlrender_browser_channel", 277 | "chrome-canary", 278 | ) 279 | 280 | await startup_htmlrender() 281 | mock_launch.assert_called_with(mocker.ANY, channel="chrome-canary") 282 | 283 | 284 | @pytest.mark.parametrize( 285 | ("system_name", "expected_path"), 286 | [ 287 | ("Windows", Path.home() / "AppData" / "Local" / "ms-playwright"), 288 | ("Darwin", Path.home() / "Library" / "Caches" / "ms-playwright"), 289 | ("Linux", Path.home() / ".cache" / "ms-playwright"), 290 | ], 291 | ids=["windows", "macos", "linux"], 292 | ) 293 | def test_clean_playwright_cache( 294 | mocker: MockerFixture, system_name: str, expected_path: Path 295 | ) -> None: 296 | """测试不同操作系统下的 Playwright 缓存清理""" 297 | from nonebot_plugin_htmlrender.browser import clean_playwright_cache 298 | 299 | mocker.patch("platform.system", return_value=system_name) 300 | mocker.patch.object(Path, "exists", return_value=True) 301 | mock_rmtree = mocker.patch("shutil.rmtree") 302 | 303 | clean_playwright_cache() 304 | 305 | mock_rmtree.assert_called_once_with(str(expected_path)) 306 | 307 | 308 | def test_clean_playwright_cache_path_not_exists(mocker: MockerFixture) -> None: 309 | """测试路径不存在时的 Playwright 缓存清理""" 310 | from nonebot_plugin_htmlrender.browser import clean_playwright_cache 311 | 312 | mocker.patch.object(Path, "exists", return_value=False) 313 | mock_rmtree = mocker.patch("shutil.rmtree") 314 | 315 | clean_playwright_cache() 316 | 317 | mock_rmtree.assert_not_called() 318 | 319 | 320 | def test_clean_playwright_cache_with_error(mocker: MockerFixture) -> None: 321 | """测试清理过程中发生错误的情况""" 322 | from nonebot_plugin_htmlrender.browser import clean_playwright_cache 323 | 324 | mocker.patch("platform.system", return_value="Linux") 325 | mocker.patch.object(Path, "exists", return_value=True) 326 | mocker.patch("shutil.rmtree", side_effect=PermissionError()) 327 | mock_logger_error = mocker.patch("nonebot_plugin_htmlrender.browser.logger.error") 328 | 329 | clean_playwright_cache() 330 | 331 | mock_logger_error.assert_called_once() 332 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/templates/github-markdown-light.css: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | -ms-text-size-adjust: 100%; 3 | -webkit-text-size-adjust: 100%; 4 | margin: 0; 5 | color: #24292f; 6 | background-color: #ffffff; 7 | font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; 8 | font-size: 16px; 9 | line-height: 1.5; 10 | word-wrap: break-word; 11 | } 12 | 13 | .markdown-body .octicon { 14 | display: inline-block; 15 | fill: currentColor; 16 | vertical-align: text-bottom; 17 | } 18 | 19 | .markdown-body h1:hover .anchor .octicon-link:before, 20 | .markdown-body h2:hover .anchor .octicon-link:before, 21 | .markdown-body h3:hover .anchor .octicon-link:before, 22 | .markdown-body h4:hover .anchor .octicon-link:before, 23 | .markdown-body h5:hover .anchor .octicon-link:before, 24 | .markdown-body h6:hover .anchor .octicon-link:before { 25 | width: 16px; 26 | height: 16px; 27 | content: ' '; 28 | display: inline-block; 29 | background-color: currentColor; 30 | -webkit-mask-image: url("data:image/svg+xml,"); 31 | mask-image: url("data:image/svg+xml,"); 32 | } 33 | 34 | .markdown-body details, 35 | .markdown-body figcaption, 36 | .markdown-body figure { 37 | display: block; 38 | } 39 | 40 | .markdown-body summary { 41 | display: list-item; 42 | } 43 | 44 | .markdown-body [hidden] { 45 | display: none !important; 46 | } 47 | 48 | .markdown-body a { 49 | background-color: transparent; 50 | color: #0969da; 51 | text-decoration: none; 52 | } 53 | 54 | .markdown-body a:active, 55 | .markdown-body a:hover { 56 | outline-width: 0; 57 | } 58 | 59 | .markdown-body abbr[title] { 60 | border-bottom: none; 61 | text-decoration: underline dotted; 62 | } 63 | 64 | .markdown-body b, 65 | .markdown-body strong { 66 | font-weight: 600; 67 | } 68 | 69 | .markdown-body dfn { 70 | font-style: italic; 71 | } 72 | 73 | .markdown-body h1 { 74 | margin: .67em 0; 75 | font-weight: 600; 76 | padding-bottom: .3em; 77 | font-size: 2em; 78 | border-bottom: 1px solid hsla(210,18%,87%,1); 79 | } 80 | 81 | .markdown-body mark { 82 | background-color: #fff8c5; 83 | color: #24292f; 84 | } 85 | 86 | .markdown-body small { 87 | font-size: 90%; 88 | } 89 | 90 | .markdown-body sub, 91 | .markdown-body sup { 92 | font-size: 75%; 93 | line-height: 0; 94 | position: relative; 95 | vertical-align: baseline; 96 | } 97 | 98 | .markdown-body sub { 99 | bottom: -0.25em; 100 | } 101 | 102 | .markdown-body sup { 103 | top: -0.5em; 104 | } 105 | 106 | .markdown-body img { 107 | border-style: none; 108 | max-width: 100%; 109 | box-sizing: content-box; 110 | background-color: #ffffff; 111 | } 112 | 113 | .markdown-body code, 114 | .markdown-body kbd, 115 | .markdown-body pre, 116 | .markdown-body samp { 117 | font-family: monospace,monospace; 118 | font-size: 1em; 119 | } 120 | 121 | .markdown-body figure { 122 | margin: 1em 40px; 123 | } 124 | 125 | .markdown-body hr { 126 | box-sizing: content-box; 127 | overflow: hidden; 128 | background: transparent; 129 | border-bottom: 1px solid hsla(210,18%,87%,1); 130 | height: .25em; 131 | padding: 0; 132 | margin: 24px 0; 133 | background-color: #d0d7de; 134 | border: 0; 135 | } 136 | 137 | .markdown-body input { 138 | font: inherit; 139 | margin: 0; 140 | overflow: visible; 141 | font-family: inherit; 142 | font-size: inherit; 143 | line-height: inherit; 144 | } 145 | 146 | .markdown-body [type=button], 147 | .markdown-body [type=reset], 148 | .markdown-body [type=submit] { 149 | -webkit-appearance: button; 150 | } 151 | 152 | .markdown-body [type=button]::-moz-focus-inner, 153 | .markdown-body [type=reset]::-moz-focus-inner, 154 | .markdown-body [type=submit]::-moz-focus-inner { 155 | border-style: none; 156 | padding: 0; 157 | } 158 | 159 | .markdown-body [type=button]:-moz-focusring, 160 | .markdown-body [type=reset]:-moz-focusring, 161 | .markdown-body [type=submit]:-moz-focusring { 162 | outline: 1px dotted ButtonText; 163 | } 164 | 165 | .markdown-body [type=checkbox], 166 | .markdown-body [type=radio] { 167 | box-sizing: border-box; 168 | padding: 0; 169 | } 170 | 171 | .markdown-body [type=number]::-webkit-inner-spin-button, 172 | .markdown-body [type=number]::-webkit-outer-spin-button { 173 | height: auto; 174 | } 175 | 176 | .markdown-body [type=search] { 177 | -webkit-appearance: textfield; 178 | outline-offset: -2px; 179 | } 180 | 181 | .markdown-body [type=search]::-webkit-search-cancel-button, 182 | .markdown-body [type=search]::-webkit-search-decoration { 183 | -webkit-appearance: none; 184 | } 185 | 186 | .markdown-body ::-webkit-input-placeholder { 187 | color: inherit; 188 | opacity: .54; 189 | } 190 | 191 | .markdown-body ::-webkit-file-upload-button { 192 | -webkit-appearance: button; 193 | font: inherit; 194 | } 195 | 196 | .markdown-body a:hover { 197 | text-decoration: underline; 198 | } 199 | 200 | .markdown-body hr::before { 201 | display: table; 202 | content: ""; 203 | } 204 | 205 | .markdown-body hr::after { 206 | display: table; 207 | clear: both; 208 | content: ""; 209 | } 210 | 211 | .markdown-body table { 212 | border-spacing: 0; 213 | border-collapse: collapse; 214 | display: block; 215 | width: max-content; 216 | max-width: 100%; 217 | overflow: auto; 218 | } 219 | 220 | .markdown-body td, 221 | .markdown-body th { 222 | padding: 0; 223 | } 224 | 225 | .markdown-body details summary { 226 | cursor: pointer; 227 | } 228 | 229 | .markdown-body details:not([open])>*:not(summary) { 230 | display: none !important; 231 | } 232 | 233 | .markdown-body kbd { 234 | display: inline-block; 235 | padding: 3px 5px; 236 | font: 11px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; 237 | line-height: 10px; 238 | color: #24292f; 239 | vertical-align: middle; 240 | background-color: #f6f8fa; 241 | border: solid 1px rgba(175,184,193,0.2); 242 | border-bottom-color: rgba(175,184,193,0.2); 243 | border-radius: 6px; 244 | box-shadow: inset 0 -1px 0 rgba(175,184,193,0.2); 245 | } 246 | 247 | .markdown-body h1, 248 | .markdown-body h2, 249 | .markdown-body h3, 250 | .markdown-body h4, 251 | .markdown-body h5, 252 | .markdown-body h6 { 253 | margin-top: 24px; 254 | margin-bottom: 16px; 255 | font-weight: 600; 256 | line-height: 1.25; 257 | } 258 | 259 | .markdown-body h2 { 260 | font-weight: 600; 261 | padding-bottom: .3em; 262 | font-size: 1.5em; 263 | border-bottom: 1px solid hsla(210,18%,87%,1); 264 | } 265 | 266 | .markdown-body h3 { 267 | font-weight: 600; 268 | font-size: 1.25em; 269 | } 270 | 271 | .markdown-body h4 { 272 | font-weight: 600; 273 | font-size: 1em; 274 | } 275 | 276 | .markdown-body h5 { 277 | font-weight: 600; 278 | font-size: .875em; 279 | } 280 | 281 | .markdown-body h6 { 282 | font-weight: 600; 283 | font-size: .85em; 284 | color: #57606a; 285 | } 286 | 287 | .markdown-body p { 288 | margin-top: 0; 289 | margin-bottom: 10px; 290 | } 291 | 292 | .markdown-body blockquote { 293 | margin: 0; 294 | padding: 0 1em; 295 | color: #57606a; 296 | border-left: .25em solid #d0d7de; 297 | } 298 | 299 | .markdown-body ul, 300 | .markdown-body ol { 301 | margin-top: 0; 302 | margin-bottom: 0; 303 | padding-left: 2em; 304 | } 305 | 306 | .markdown-body ol ol, 307 | .markdown-body ul ol { 308 | list-style-type: lower-roman; 309 | } 310 | 311 | .markdown-body ul ul ol, 312 | .markdown-body ul ol ol, 313 | .markdown-body ol ul ol, 314 | .markdown-body ol ol ol { 315 | list-style-type: lower-alpha; 316 | } 317 | 318 | .markdown-body dd { 319 | margin-left: 0; 320 | } 321 | 322 | .markdown-body tt, 323 | .markdown-body code { 324 | font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; 325 | font-size: 12px; 326 | } 327 | 328 | .markdown-body pre { 329 | margin-top: 0; 330 | margin-bottom: 0; 331 | font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; 332 | font-size: 12px; 333 | word-wrap: normal; 334 | } 335 | 336 | .markdown-body .octicon { 337 | display: inline-block; 338 | overflow: visible !important; 339 | vertical-align: text-bottom; 340 | fill: currentColor; 341 | } 342 | 343 | .markdown-body ::placeholder { 344 | color: #6e7781; 345 | opacity: 1; 346 | } 347 | 348 | .markdown-body input::-webkit-outer-spin-button, 349 | .markdown-body input::-webkit-inner-spin-button { 350 | margin: 0; 351 | -webkit-appearance: none; 352 | appearance: none; 353 | } 354 | 355 | .markdown-body .pl-c { 356 | color: #6e7781; 357 | } 358 | 359 | .markdown-body .pl-c1, 360 | .markdown-body .pl-s .pl-v { 361 | color: #0550ae; 362 | } 363 | 364 | .markdown-body .pl-e, 365 | .markdown-body .pl-en { 366 | color: #8250df; 367 | } 368 | 369 | .markdown-body .pl-smi, 370 | .markdown-body .pl-s .pl-s1 { 371 | color: #24292f; 372 | } 373 | 374 | .markdown-body .pl-ent { 375 | color: #116329; 376 | } 377 | 378 | .markdown-body .pl-k { 379 | color: #cf222e; 380 | } 381 | 382 | .markdown-body .pl-s, 383 | .markdown-body .pl-pds, 384 | .markdown-body .pl-s .pl-pse .pl-s1, 385 | .markdown-body .pl-sr, 386 | .markdown-body .pl-sr .pl-cce, 387 | .markdown-body .pl-sr .pl-sre, 388 | .markdown-body .pl-sr .pl-sra { 389 | color: #0a3069; 390 | } 391 | 392 | .markdown-body .pl-v, 393 | .markdown-body .pl-smw { 394 | color: #953800; 395 | } 396 | 397 | .markdown-body .pl-bu { 398 | color: #82071e; 399 | } 400 | 401 | .markdown-body .pl-ii { 402 | color: #f6f8fa; 403 | background-color: #82071e; 404 | } 405 | 406 | .markdown-body .pl-c2 { 407 | color: #f6f8fa; 408 | background-color: #cf222e; 409 | } 410 | 411 | .markdown-body .pl-sr .pl-cce { 412 | font-weight: bold; 413 | color: #116329; 414 | } 415 | 416 | .markdown-body .pl-ml { 417 | color: #3b2300; 418 | } 419 | 420 | .markdown-body .pl-mh, 421 | .markdown-body .pl-mh .pl-en, 422 | .markdown-body .pl-ms { 423 | font-weight: bold; 424 | color: #0550ae; 425 | } 426 | 427 | .markdown-body .pl-mi { 428 | font-style: italic; 429 | color: #24292f; 430 | } 431 | 432 | .markdown-body .pl-mb { 433 | font-weight: bold; 434 | color: #24292f; 435 | } 436 | 437 | .markdown-body .pl-md { 438 | color: #82071e; 439 | background-color: #FFEBE9; 440 | } 441 | 442 | .markdown-body .pl-mi1 { 443 | color: #116329; 444 | background-color: #dafbe1; 445 | } 446 | 447 | .markdown-body .pl-mc { 448 | color: #953800; 449 | background-color: #ffd8b5; 450 | } 451 | 452 | .markdown-body .pl-mi2 { 453 | color: #eaeef2; 454 | background-color: #0550ae; 455 | } 456 | 457 | .markdown-body .pl-mdr { 458 | font-weight: bold; 459 | color: #8250df; 460 | } 461 | 462 | .markdown-body .pl-ba { 463 | color: #57606a; 464 | } 465 | 466 | .markdown-body .pl-sg { 467 | color: #8c959f; 468 | } 469 | 470 | .markdown-body .pl-corl { 471 | text-decoration: underline; 472 | color: #0a3069; 473 | } 474 | 475 | .markdown-body [data-catalyst] { 476 | display: block; 477 | } 478 | 479 | .markdown-body g-emoji { 480 | font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; 481 | font-size: 1em; 482 | font-style: normal !important; 483 | font-weight: 400; 484 | line-height: 1; 485 | vertical-align: -0.075em; 486 | } 487 | 488 | .markdown-body g-emoji img { 489 | width: 1em; 490 | height: 1em; 491 | } 492 | 493 | .markdown-body::before { 494 | display: table; 495 | content: ""; 496 | } 497 | 498 | .markdown-body::after { 499 | display: table; 500 | clear: both; 501 | content: ""; 502 | } 503 | 504 | .markdown-body>*:first-child { 505 | margin-top: 0 !important; 506 | } 507 | 508 | .markdown-body>*:last-child { 509 | margin-bottom: 0 !important; 510 | } 511 | 512 | .markdown-body a:not([href]) { 513 | color: inherit; 514 | text-decoration: none; 515 | } 516 | 517 | .markdown-body .absent { 518 | color: #cf222e; 519 | } 520 | 521 | .markdown-body .anchor { 522 | float: left; 523 | padding-right: 4px; 524 | margin-left: -20px; 525 | line-height: 1; 526 | } 527 | 528 | .markdown-body .anchor:focus { 529 | outline: none; 530 | } 531 | 532 | .markdown-body p, 533 | .markdown-body blockquote, 534 | .markdown-body ul, 535 | .markdown-body ol, 536 | .markdown-body dl, 537 | .markdown-body table, 538 | .markdown-body pre, 539 | .markdown-body details { 540 | margin-top: 0; 541 | margin-bottom: 16px; 542 | } 543 | 544 | .markdown-body blockquote>:first-child { 545 | margin-top: 0; 546 | } 547 | 548 | .markdown-body blockquote>:last-child { 549 | margin-bottom: 0; 550 | } 551 | 552 | .markdown-body sup>a::before { 553 | content: "["; 554 | } 555 | 556 | .markdown-body sup>a::after { 557 | content: "]"; 558 | } 559 | 560 | .markdown-body h1 .octicon-link, 561 | .markdown-body h2 .octicon-link, 562 | .markdown-body h3 .octicon-link, 563 | .markdown-body h4 .octicon-link, 564 | .markdown-body h5 .octicon-link, 565 | .markdown-body h6 .octicon-link { 566 | color: #24292f; 567 | vertical-align: middle; 568 | visibility: hidden; 569 | } 570 | 571 | .markdown-body h1:hover .anchor, 572 | .markdown-body h2:hover .anchor, 573 | .markdown-body h3:hover .anchor, 574 | .markdown-body h4:hover .anchor, 575 | .markdown-body h5:hover .anchor, 576 | .markdown-body h6:hover .anchor { 577 | text-decoration: none; 578 | } 579 | 580 | .markdown-body h1:hover .anchor .octicon-link, 581 | .markdown-body h2:hover .anchor .octicon-link, 582 | .markdown-body h3:hover .anchor .octicon-link, 583 | .markdown-body h4:hover .anchor .octicon-link, 584 | .markdown-body h5:hover .anchor .octicon-link, 585 | .markdown-body h6:hover .anchor .octicon-link { 586 | visibility: visible; 587 | } 588 | 589 | .markdown-body h1 tt, 590 | .markdown-body h1 code, 591 | .markdown-body h2 tt, 592 | .markdown-body h2 code, 593 | .markdown-body h3 tt, 594 | .markdown-body h3 code, 595 | .markdown-body h4 tt, 596 | .markdown-body h4 code, 597 | .markdown-body h5 tt, 598 | .markdown-body h5 code, 599 | .markdown-body h6 tt, 600 | .markdown-body h6 code { 601 | padding: 0 .2em; 602 | font-size: inherit; 603 | } 604 | 605 | .markdown-body ul.no-list, 606 | .markdown-body ol.no-list { 607 | padding: 0; 608 | list-style-type: none; 609 | } 610 | 611 | .markdown-body ol[type="1"] { 612 | list-style-type: decimal; 613 | } 614 | 615 | .markdown-body ol[type=a] { 616 | list-style-type: lower-alpha; 617 | } 618 | 619 | .markdown-body ol[type=i] { 620 | list-style-type: lower-roman; 621 | } 622 | 623 | .markdown-body div>ol:not([type]) { 624 | list-style-type: decimal; 625 | } 626 | 627 | .markdown-body ul ul, 628 | .markdown-body ul ol, 629 | .markdown-body ol ol, 630 | .markdown-body ol ul { 631 | margin-top: 0; 632 | margin-bottom: 0; 633 | } 634 | 635 | .markdown-body li>p { 636 | margin-top: 16px; 637 | } 638 | 639 | .markdown-body li+li { 640 | margin-top: .25em; 641 | } 642 | 643 | .markdown-body dl { 644 | padding: 0; 645 | } 646 | 647 | .markdown-body dl dt { 648 | padding: 0; 649 | margin-top: 16px; 650 | font-size: 1em; 651 | font-style: italic; 652 | font-weight: 600; 653 | } 654 | 655 | .markdown-body dl dd { 656 | padding: 0 16px; 657 | margin-bottom: 16px; 658 | } 659 | 660 | .markdown-body table th { 661 | font-weight: 600; 662 | } 663 | 664 | .markdown-body table th, 665 | .markdown-body table td { 666 | padding: 6px 13px; 667 | border: 1px solid #d0d7de; 668 | } 669 | 670 | .markdown-body table tr { 671 | background-color: #ffffff; 672 | border-top: 1px solid hsla(210,18%,87%,1); 673 | } 674 | 675 | .markdown-body table tr:nth-child(2n) { 676 | background-color: #f6f8fa; 677 | } 678 | 679 | .markdown-body table img { 680 | background-color: transparent; 681 | } 682 | 683 | .markdown-body img[align=right] { 684 | padding-left: 20px; 685 | } 686 | 687 | .markdown-body img[align=left] { 688 | padding-right: 20px; 689 | } 690 | 691 | .markdown-body .emoji { 692 | max-width: none; 693 | vertical-align: text-top; 694 | background-color: transparent; 695 | } 696 | 697 | .markdown-body span.frame { 698 | display: block; 699 | overflow: hidden; 700 | } 701 | 702 | .markdown-body span.frame>span { 703 | display: block; 704 | float: left; 705 | width: auto; 706 | padding: 7px; 707 | margin: 13px 0 0; 708 | overflow: hidden; 709 | border: 1px solid #d0d7de; 710 | } 711 | 712 | .markdown-body span.frame span img { 713 | display: block; 714 | float: left; 715 | } 716 | 717 | .markdown-body span.frame span span { 718 | display: block; 719 | padding: 5px 0 0; 720 | clear: both; 721 | color: #24292f; 722 | } 723 | 724 | .markdown-body span.align-center { 725 | display: block; 726 | overflow: hidden; 727 | clear: both; 728 | } 729 | 730 | .markdown-body span.align-center>span { 731 | display: block; 732 | margin: 13px auto 0; 733 | overflow: hidden; 734 | text-align: center; 735 | } 736 | 737 | .markdown-body span.align-center span img { 738 | margin: 0 auto; 739 | text-align: center; 740 | } 741 | 742 | .markdown-body span.align-right { 743 | display: block; 744 | overflow: hidden; 745 | clear: both; 746 | } 747 | 748 | .markdown-body span.align-right>span { 749 | display: block; 750 | margin: 13px 0 0; 751 | overflow: hidden; 752 | text-align: right; 753 | } 754 | 755 | .markdown-body span.align-right span img { 756 | margin: 0; 757 | text-align: right; 758 | } 759 | 760 | .markdown-body span.float-left { 761 | display: block; 762 | float: left; 763 | margin-right: 13px; 764 | overflow: hidden; 765 | } 766 | 767 | .markdown-body span.float-left span { 768 | margin: 13px 0 0; 769 | } 770 | 771 | .markdown-body span.float-right { 772 | display: block; 773 | float: right; 774 | margin-left: 13px; 775 | overflow: hidden; 776 | } 777 | 778 | .markdown-body span.float-right>span { 779 | display: block; 780 | margin: 13px auto 0; 781 | overflow: hidden; 782 | text-align: right; 783 | } 784 | 785 | .markdown-body code, 786 | .markdown-body tt { 787 | padding: .2em .4em; 788 | margin: 0; 789 | font-size: 85%; 790 | background-color: rgba(175,184,193,0.2); 791 | border-radius: 6px; 792 | } 793 | 794 | .markdown-body code br, 795 | .markdown-body tt br { 796 | display: none; 797 | } 798 | 799 | .markdown-body del code { 800 | text-decoration: inherit; 801 | } 802 | 803 | .markdown-body pre code { 804 | font-size: 100%; 805 | } 806 | 807 | .markdown-body pre>code { 808 | padding: 0; 809 | margin: 0; 810 | word-break: normal; 811 | white-space: pre; 812 | background: transparent; 813 | border: 0; 814 | } 815 | 816 | .markdown-body .highlight { 817 | margin-bottom: 16px; 818 | } 819 | 820 | .markdown-body .highlight pre { 821 | margin-bottom: 0; 822 | word-break: normal; 823 | } 824 | 825 | .markdown-body .highlight pre, 826 | .markdown-body pre { 827 | padding: 16px; 828 | overflow: auto; 829 | font-size: 85%; 830 | line-height: 1.45; 831 | background-color: #f6f8fa; 832 | border-radius: 6px; 833 | } 834 | 835 | .markdown-body pre code, 836 | .markdown-body pre tt { 837 | display: inline; 838 | max-width: auto; 839 | padding: 0; 840 | margin: 0; 841 | overflow: visible; 842 | line-height: inherit; 843 | word-wrap: normal; 844 | background-color: transparent; 845 | border: 0; 846 | } 847 | 848 | .markdown-body .csv-data td, 849 | .markdown-body .csv-data th { 850 | padding: 5px; 851 | overflow: hidden; 852 | font-size: 12px; 853 | line-height: 1; 854 | text-align: left; 855 | white-space: nowrap; 856 | } 857 | 858 | .markdown-body .csv-data .blob-num { 859 | padding: 10px 8px 9px; 860 | text-align: right; 861 | background: #ffffff; 862 | border: 0; 863 | } 864 | 865 | .markdown-body .csv-data tr { 866 | border-top: 0; 867 | } 868 | 869 | .markdown-body .csv-data th { 870 | font-weight: 600; 871 | background: #f6f8fa; 872 | border-top: 0; 873 | } 874 | 875 | .markdown-body .footnotes { 876 | font-size: 12px; 877 | color: #57606a; 878 | border-top: 1px solid #d0d7de; 879 | } 880 | 881 | .markdown-body .footnotes ol { 882 | padding-left: 16px; 883 | } 884 | 885 | .markdown-body .footnotes li { 886 | position: relative; 887 | } 888 | 889 | .markdown-body .footnotes li:target::before { 890 | position: absolute; 891 | top: -8px; 892 | right: -8px; 893 | bottom: -8px; 894 | left: -24px; 895 | pointer-events: none; 896 | content: ""; 897 | border: 2px solid #0969da; 898 | border-radius: 6px; 899 | } 900 | 901 | .markdown-body .footnotes li:target { 902 | color: #24292f; 903 | } 904 | 905 | .markdown-body .footnotes .data-footnote-backref g-emoji { 906 | font-family: monospace; 907 | } 908 | 909 | .markdown-body .task-list-item { 910 | list-style-type: none; 911 | } 912 | 913 | .markdown-body .task-list-item label { 914 | font-weight: 400; 915 | } 916 | 917 | .markdown-body .task-list-item.enabled label { 918 | cursor: pointer; 919 | } 920 | 921 | .markdown-body .task-list-item+.task-list-item { 922 | margin-top: 3px; 923 | } 924 | 925 | .markdown-body .task-list-item .handle { 926 | display: none; 927 | } 928 | 929 | .markdown-body .task-list-item-checkbox { 930 | margin: 0 .2em .25em -1.6em; 931 | vertical-align: middle; 932 | } 933 | 934 | .markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { 935 | margin: 0 -1.6em .25em .2em; 936 | } 937 | 938 | .markdown-body ::-webkit-calendar-picker-indicator { 939 | filter: invert(50%); 940 | } 941 | -------------------------------------------------------------------------------- /nonebot_plugin_htmlrender/templates/katex/mhchem.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e(require("katex"));else if("function"==typeof define&&define.amd)define(["katex"],e);else{var n="object"==typeof exports?e(require("katex")):e(t.katex);for(var o in n)("object"==typeof exports?exports:t)[o]=n[o]}}("undefined"!=typeof self?self:this,(function(t){return function(){"use strict";var e={771:function(e){e.exports=t}},n={};function o(t){var a=n[t];if(void 0!==a)return a.exports;var r=n[t]={exports:{}};return e[t](r,r.exports,o),r.exports}o.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return o.d(e,{a:e}),e},o.d=function(t,e){for(var n in e)o.o(e,n)&&!o.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},o.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)};var a={};return function(){var t=o(771),e=o.n(t);e().__defineMacro("\\ce",(function(t){return n(t.consumeArgs(1)[0],"ce")})),e().__defineMacro("\\pu",(function(t){return n(t.consumeArgs(1)[0],"pu")})),e().__defineMacro("\\tripledash","{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}");var n=function(t,e){for(var n="",o=t.length&&t[t.length-1].loc.start,i=t.length-1;i>=0;i--)t[i].loc.start>o&&(n+=" ",o=t[i].loc.start),n+=t[i].text,o+=t[i].text.length;return r.go(a.go(n,e))},a={go:function(t,e){if(!t)return[];void 0===e&&(e="ce");var n,o="0",r={};r.parenthesisLevel=0,t=(t=(t=t.replace(/\n/g," ")).replace(/[\u2212\u2013\u2014\u2010]/g,"-")).replace(/[\u2026]/g,"...");for(var i=10,c=[];;){n!==t?(i=10,n=t):i--;var u=a.stateMachines[e],p=u.transitions[o]||u.transitions["*"];t:for(var s=0;s0))return c;if(d.revisit||(t=_.remainder),!d.toContinue)break t}}if(i<=0)throw["MhchemBugU","mhchem bug U. Please report."]}},concatArray:function(t,e){if(e)if(Array.isArray(e))for(var n=0;n":/^[=<>]/,"#":/^[#\u2261]/,"+":/^\+/,"-$":/^-(?=[\s_},;\]/]|$|\([a-z]+\))/,"-9":/^-(?=[0-9])/,"- orbital overlap":/^-(?=(?:[spd]|sp)(?:$|[\s,;\)\]\}]))/,"-":/^-/,"pm-operator":/^(?:\\pm|\$\\pm\$|\+-|\+\/-)/,operator:/^(?:\+|(?:[\-=<>]|<<|>>|\\approx|\$\\approx\$)(?=\s|$|-?[0-9]))/,arrowUpDown:/^(?:v|\(v\)|\^|\(\^\))(?=$|[\s,;\)\]\}])/,"\\bond{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\bond{","","","}")},"->":/^(?:<->|<-->|->|<-|<=>>|<<=>|<=>|[\u2192\u27F6\u21CC])/,CMT:/^[CMT](?=\[)/,"[(...)]":function(t){return a.patterns.findObserveGroups(t,"[","","","]")},"1st-level escape":/^(&|\\\\|\\hline)\s*/,"\\,":/^(?:\\[,\ ;:])/,"\\x{}{}":function(t){return a.patterns.findObserveGroups(t,"",/^\\[a-zA-Z]+\{/,"}","","","{","}","",!0)},"\\x{}":function(t){return a.patterns.findObserveGroups(t,"",/^\\[a-zA-Z]+\{/,"}","")},"\\ca":/^\\ca(?:\s+|(?![a-zA-Z]))/,"\\x":/^(?:\\[a-zA-Z]+\s*|\\[_&{}%])/,orbital:/^(?:[0-9]{1,2}[spdfgh]|[0-9]{0,2}sp)(?=$|[^a-zA-Z])/,others:/^[\/~|]/,"\\frac{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\frac{","","","}","{","","","}")},"\\overset{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\overset{","","","}","{","","","}")},"\\underset{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\underset{","","","}","{","","","}")},"\\underbrace{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\underbrace{","","","}_","{","","","}")},"\\color{(...)}0":function(t){return a.patterns.findObserveGroups(t,"\\color{","","","}")},"\\color{(...)}{(...)}1":function(t){return a.patterns.findObserveGroups(t,"\\color{","","","}","{","","","}")},"\\color(...){(...)}2":function(t){return a.patterns.findObserveGroups(t,"\\color","\\","",/^(?=\{)/,"{","","","}")},"\\ce{(...)}":function(t){return a.patterns.findObserveGroups(t,"\\ce{","","","}")},oxidation$:/^(?:[+-][IVX]+|\\pm\s*0|\$\\pm\$\s*0)$/,"d-oxidation$":/^(?:[+-]?\s?[IVX]+|\\pm\s*0|\$\\pm\$\s*0)$/,"roman numeral":/^[IVX]+/,"1/2$":/^[+\-]?(?:[0-9]+|\$[a-z]\$|[a-z])\/[0-9]+(?:\$[a-z]\$|[a-z])?$/,amount:function(t){var e;if(e=t.match(/^(?:(?:(?:\([+\-]?[0-9]+\/[0-9]+\)|[+\-]?(?:[0-9]+|\$[a-z]\$|[a-z])\/[0-9]+|[+\-]?[0-9]+[.,][0-9]+|[+\-]?\.[0-9]+|[+\-]?[0-9]+)(?:[a-z](?=\s*[A-Z]))?)|[+\-]?[a-z](?=\s*[A-Z])|\+(?!\s))/))return{match_:e[0],remainder:t.substr(e[0].length)};var n=a.patterns.findObserveGroups(t,"","$","$","");return n&&(e=n.match_.match(/^\$(?:\(?[+\-]?(?:[0-9]*[a-z]?[+\-])?[0-9]*[a-z](?:[+\-][0-9]*[a-z]?)?\)?|\+|-)\$$/))?{match_:e[0],remainder:t.substr(e[0].length)}:null},amount2:function(t){return this.amount(t)},"(KV letters),":/^(?:[A-Z][a-z]{0,2}|i)(?=,)/,formula$:function(t){if(t.match(/^\([a-z]+\)$/))return null;var e=t.match(/^(?:[a-z]|(?:[0-9\ \+\-\,\.\(\)]+[a-z])+[0-9\ \+\-\,\.\(\)]*|(?:[a-z][0-9\ \+\-\,\.\(\)]+)+[a-z]?)$/);return e?{match_:e[0],remainder:t.substr(e[0].length)}:null},uprightEntities:/^(?:pH|pOH|pC|pK|iPr|iBu)(?=$|[^a-zA-Z])/,"/":/^\s*(\/)\s*/,"//":/^\s*(\/\/)\s*/,"*":/^\s*[*.]\s*/},findObserveGroups:function(t,e,n,o,a,r,i,c,u,p){var s=function(t,e){if("string"==typeof e)return 0!==t.indexOf(e)?null:e;var n=t.match(e);return n?n[0]:null},_=s(t,e);if(null===_)return null;if(t=t.substr(_.length),null===(_=s(t,n)))return null;var d=function(t,e,n){for(var o=0;e":{"0|1|2|3":{action_:"r=",nextState:"r"},"a|as":{action_:["output","r="],nextState:"r"},"*":{action_:["output","r="],nextState:"r"}},"+":{o:{action_:"d= kv",nextState:"d"},"d|D":{action_:"d=",nextState:"d"},q:{action_:"d=",nextState:"qd"},"qd|qD":{action_:"d=",nextState:"qd"},dq:{action_:["output","d="],nextState:"d"},3:{action_:["sb=false","output","operator"],nextState:"0"}},amount:{"0|2":{action_:"a=",nextState:"a"}},"pm-operator":{"0|1|2|a|as":{action_:["sb=false","output",{type_:"operator",option:"\\pm"}],nextState:"0"}},operator:{"0|1|2|a|as":{action_:["sb=false","output","operator"],nextState:"0"}},"-$":{"o|q":{action_:["charge or bond","output"],nextState:"qd"},d:{action_:"d=",nextState:"d"},D:{action_:["output",{type_:"bond",option:"-"}],nextState:"3"},q:{action_:"d=",nextState:"qd"},qd:{action_:"d=",nextState:"qd"},"qD|dq":{action_:["output",{type_:"bond",option:"-"}],nextState:"3"}},"-9":{"3|o":{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"3"}},"- orbital overlap":{o:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"},d:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"}},"-":{"0|1|2":{action_:[{type_:"output",option:1},"beginsWithBond=true",{type_:"bond",option:"-"}],nextState:"3"},3:{action_:{type_:"bond",option:"-"}},a:{action_:["output",{type_:"insert",option:"hyphen"}],nextState:"2"},as:{action_:[{type_:"output",option:2},{type_:"bond",option:"-"}],nextState:"3"},b:{action_:"b="},o:{action_:{type_:"- after o/d",option:!1},nextState:"2"},q:{action_:{type_:"- after o/d",option:!1},nextState:"2"},"d|qd|dq":{action_:{type_:"- after o/d",option:!0},nextState:"2"},"D|qD|p":{action_:["output",{type_:"bond",option:"-"}],nextState:"3"}},amount2:{"1|3":{action_:"a=",nextState:"a"}},letters:{"0|1|2|3|a|as|b|p|bp|o":{action_:"o=",nextState:"o"},"q|dq":{action_:["output","o="],nextState:"o"},"d|D|qd|qD":{action_:"o after d",nextState:"o"}},digits:{o:{action_:"q=",nextState:"q"},"d|D":{action_:"q=",nextState:"dq"},q:{action_:["output","o="],nextState:"o"},a:{action_:"o=",nextState:"o"}},"space A":{"b|p|bp":{}},space:{a:{nextState:"as"},0:{action_:"sb=false"},"1|2":{action_:"sb=true"},"r|rt|rd|rdt|rdq":{action_:"output",nextState:"0"},"*":{action_:["output","sb=true"],nextState:"1"}},"1st-level escape":{"1|2":{action_:["output",{type_:"insert+p1",option:"1st-level escape"}]},"*":{action_:["output",{type_:"insert+p1",option:"1st-level escape"}],nextState:"0"}},"[(...)]":{"r|rt":{action_:"rd=",nextState:"rd"},"rd|rdt":{action_:"rq=",nextState:"rdq"}},"...":{"o|d|D|dq|qd|qD":{action_:["output",{type_:"bond",option:"..."}],nextState:"3"},"*":{action_:[{type_:"output",option:1},{type_:"insert",option:"ellipsis"}],nextState:"1"}},". |* ":{"*":{action_:["output",{type_:"insert",option:"addition compound"}],nextState:"1"}},"state of aggregation $":{"*":{action_:["output","state of aggregation"],nextState:"1"}},"{[(":{"a|as|o":{action_:["o=","output","parenthesisLevel++"],nextState:"2"},"0|1|2|3":{action_:["o=","output","parenthesisLevel++"],nextState:"2"},"*":{action_:["output","o=","output","parenthesisLevel++"],nextState:"2"}},")]}":{"0|1|2|3|b|p|bp|o":{action_:["o=","parenthesisLevel--"],nextState:"o"},"a|as|d|D|q|qd|qD|dq":{action_:["output","o=","parenthesisLevel--"],nextState:"o"}},", ":{"*":{action_:["output","comma"],nextState:"0"}},"^_":{"*":{}},"^{(...)}|^($...$)":{"0|1|2|as":{action_:"b=",nextState:"b"},p:{action_:"b=",nextState:"bp"},"3|o":{action_:"d= kv",nextState:"D"},q:{action_:"d=",nextState:"qD"},"d|D|qd|qD|dq":{action_:["output","d="],nextState:"D"}},"^a|^\\x{}{}|^\\x{}|^\\x|'":{"0|1|2|as":{action_:"b=",nextState:"b"},p:{action_:"b=",nextState:"bp"},"3|o":{action_:"d= kv",nextState:"d"},q:{action_:"d=",nextState:"qd"},"d|qd|D|qD":{action_:"d="},dq:{action_:["output","d="],nextState:"d"}},"_{(state of aggregation)}$":{"d|D|q|qd|qD|dq":{action_:["output","q="],nextState:"q"}},"_{(...)}|_($...$)|_9|_\\x{}{}|_\\x{}|_\\x":{"0|1|2|as":{action_:"p=",nextState:"p"},b:{action_:"p=",nextState:"bp"},"3|o":{action_:"q=",nextState:"q"},"d|D":{action_:"q=",nextState:"dq"},"q|qd|qD|dq":{action_:["output","q="],nextState:"q"}},"=<>":{"0|1|2|3|a|as|o|q|d|D|qd|qD|dq":{action_:[{type_:"output",option:2},"bond"],nextState:"3"}},"#":{"0|1|2|3|a|as|o":{action_:[{type_:"output",option:2},{type_:"bond",option:"#"}],nextState:"3"}},"{}":{"*":{action_:{type_:"output",option:1},nextState:"1"}},"{...}":{"0|1|2|3|a|as|b|p|bp":{action_:"o=",nextState:"o"},"o|d|D|q|qd|qD|dq":{action_:["output","o="],nextState:"o"}},"$...$":{a:{action_:"a="},"0|1|2|3|as|b|p|bp|o":{action_:"o=",nextState:"o"},"as|o":{action_:"o="},"q|d|D|qd|qD|dq":{action_:["output","o="],nextState:"o"}},"\\bond{(...)}":{"*":{action_:[{type_:"output",option:2},"bond"],nextState:"3"}},"\\frac{(...)}":{"*":{action_:[{type_:"output",option:1},"frac-output"],nextState:"3"}},"\\overset{(...)}":{"*":{action_:[{type_:"output",option:2},"overset-output"],nextState:"3"}},"\\underset{(...)}":{"*":{action_:[{type_:"output",option:2},"underset-output"],nextState:"3"}},"\\underbrace{(...)}":{"*":{action_:[{type_:"output",option:2},"underbrace-output"],nextState:"3"}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:[{type_:"output",option:2},"color-output"],nextState:"3"}},"\\color{(...)}0":{"*":{action_:[{type_:"output",option:2},"color0-output"]}},"\\ce{(...)}":{"*":{action_:[{type_:"output",option:2},"ce"],nextState:"3"}},"\\,":{"*":{action_:[{type_:"output",option:1},"copy"],nextState:"1"}},"\\x{}{}|\\x{}|\\x":{"0|1|2|3|a|as|b|p|bp|o|c0":{action_:["o=","output"],nextState:"3"},"*":{action_:["output","o=","output"],nextState:"3"}},others:{"*":{action_:[{type_:"output",option:1},"copy"],nextState:"3"}},else2:{a:{action_:"a to o",nextState:"o",revisit:!0},as:{action_:["output","sb=true"],nextState:"1",revisit:!0},"r|rt|rd|rdt|rdq":{action_:["output"],nextState:"0",revisit:!0},"*":{action_:["output","copy"],nextState:"3"}}}),actions:{"o after d":function(t,e){var n;if((t.d||"").match(/^[0-9]+$/)){var o=t.d;t.d=void 0,n=this.output(t),t.b=o}else n=this.output(t);return a.actions["o="](t,e),n},"d= kv":function(t,e){t.d=e,t.dType="kv"},"charge or bond":function(t,e){if(t.beginsWithBond){var n=[];return a.concatArray(n,this.output(t)),a.concatArray(n,a.actions.bond(t,e,"-")),n}t.d=e},"- after o/d":function(t,e,n){var o=a.patterns.match_("orbital",t.o||""),r=a.patterns.match_("one lowercase greek letter $",t.o||""),i=a.patterns.match_("one lowercase latin letter $",t.o||""),c=a.patterns.match_("$one lowercase latin letter$ $",t.o||""),u="-"===e&&(o&&""===o.remainder||r||i||c);!u||t.a||t.b||t.p||t.d||t.q||o||!i||(t.o="$"+t.o+"$");var p=[];return u?(a.concatArray(p,this.output(t)),p.push({type_:"hyphen"})):(o=a.patterns.match_("digits",t.d||""),n&&o&&""===o.remainder?(a.concatArray(p,a.actions["d="](t,e)),a.concatArray(p,this.output(t))):(a.concatArray(p,this.output(t)),a.concatArray(p,a.actions.bond(t,e,"-")))),p},"a to o":function(t){t.o=t.a,t.a=void 0},"sb=true":function(t){t.sb=!0},"sb=false":function(t){t.sb=!1},"beginsWithBond=true":function(t){t.beginsWithBond=!0},"beginsWithBond=false":function(t){t.beginsWithBond=!1},"parenthesisLevel++":function(t){t.parenthesisLevel++},"parenthesisLevel--":function(t){t.parenthesisLevel--},"state of aggregation":function(t,e){return{type_:"state of aggregation",p1:a.go(e,"o")}},comma:function(t,e){var n=e.replace(/\s*$/,"");return n!==e&&0===t.parenthesisLevel?{type_:"comma enumeration L",p1:n}:{type_:"comma enumeration M",p1:n}},output:function(t,e,n){var o,r,i;t.r?(r="M"===t.rdt?a.go(t.rd,"tex-math"):"T"===t.rdt?[{type_:"text",p1:t.rd||""}]:a.go(t.rd),i="M"===t.rqt?a.go(t.rq,"tex-math"):"T"===t.rqt?[{type_:"text",p1:t.rq||""}]:a.go(t.rq),o={type_:"arrow",r:t.r,rd:r,rq:i}):(o=[],(t.a||t.b||t.p||t.o||t.q||t.d||n)&&(t.sb&&o.push({type_:"entitySkip"}),t.o||t.q||t.d||t.b||t.p||2===n?t.o||t.q||t.d||!t.b&&!t.p?t.o&&"kv"===t.dType&&a.patterns.match_("d-oxidation$",t.d||"")?t.dType="oxidation":t.o&&"kv"===t.dType&&!t.q&&(t.dType=void 0):(t.o=t.a,t.d=t.b,t.q=t.p,t.a=t.b=t.p=void 0):(t.o=t.a,t.a=void 0),o.push({type_:"chemfive",a:a.go(t.a,"a"),b:a.go(t.b,"bd"),p:a.go(t.p,"pq"),o:a.go(t.o,"o"),q:a.go(t.q,"pq"),d:a.go(t.d,"oxidation"===t.dType?"oxidation":"bd"),dType:t.dType})));for(var c in t)"parenthesisLevel"!==c&&"beginsWithBond"!==c&&delete t[c];return o},"oxidation-output":function(t,e){var n=["{"];return a.concatArray(n,a.go(e,"oxidation")),n.push("}"),n},"frac-output":function(t,e){return{type_:"frac-ce",p1:a.go(e[0]),p2:a.go(e[1])}},"overset-output":function(t,e){return{type_:"overset",p1:a.go(e[0]),p2:a.go(e[1])}},"underset-output":function(t,e){return{type_:"underset",p1:a.go(e[0]),p2:a.go(e[1])}},"underbrace-output":function(t,e){return{type_:"underbrace",p1:a.go(e[0]),p2:a.go(e[1])}},"color-output":function(t,e){return{type_:"color",color1:e[0],color2:a.go(e[1])}},"r=":function(t,e){t.r=e},"rdt=":function(t,e){t.rdt=e},"rd=":function(t,e){t.rd=e},"rqt=":function(t,e){t.rqt=e},"rq=":function(t,e){t.rq=e},operator:function(t,e,n){return{type_:"operator",kind_:n||e}}}},a:{transitions:a.createTransitions({empty:{"*":{}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"1",revisit:!0}},"$(...)$":{"*":{action_:"tex-math tight",nextState:"1"}},",":{"*":{action_:{type_:"insert",option:"commaDecimal"}}},else2:{"*":{action_:"copy"}}}),actions:{}},o:{transitions:a.createTransitions({empty:{"*":{}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"1",revisit:!0}},letters:{"*":{action_:"rm"}},"\\ca":{"*":{action_:{type_:"insert",option:"circa"}}},"\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"{text}"}},else2:{"*":{action_:"copy"}}}),actions:{}},text:{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"{...}":{"*":{action_:"text="}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"\\greek":{"*":{action_:["output","rm"]}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:["output","copy"]}},else:{"*":{action_:"text="}}}),actions:{output:function(t){if(t.text_){var e={type_:"text",p1:t.text_};for(var n in t)delete t[n];return e}}}},pq:{transitions:a.createTransitions({empty:{"*":{}},"state of aggregation $":{"*":{action_:"state of aggregation"}},i$:{0:{nextState:"!f",revisit:!0}},"(KV letters),":{0:{action_:"rm",nextState:"0"}},formula$:{0:{nextState:"f",revisit:!0}},"1/2$":{0:{action_:"1/2"}},else:{0:{nextState:"!f",revisit:!0}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"text"}},"a-z":{f:{action_:"tex-math"}},letters:{"*":{action_:"rm"}},"-9.,9":{"*":{action_:"9,9"}},",":{"*":{action_:{type_:"insert+p1",option:"comma enumeration S"}}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:"color-output"}},"\\color{(...)}0":{"*":{action_:"color0-output"}},"\\ce{(...)}":{"*":{action_:"ce"}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},else2:{"*":{action_:"copy"}}}),actions:{"state of aggregation":function(t,e){return{type_:"state of aggregation subscript",p1:a.go(e,"o")}},"color-output":function(t,e){return{type_:"color",color1:e[0],color2:a.go(e[1],"pq")}}}},bd:{transitions:a.createTransitions({empty:{"*":{}},x$:{0:{nextState:"!f",revisit:!0}},formula$:{0:{nextState:"f",revisit:!0}},else:{0:{nextState:"!f",revisit:!0}},"-9.,9 no missing 0":{"*":{action_:"9,9"}},".":{"*":{action_:{type_:"insert",option:"electron dot"}}},"a-z":{f:{action_:"tex-math"}},x:{"*":{action_:{type_:"insert",option:"KV x"}}},letters:{"*":{action_:"rm"}},"'":{"*":{action_:{type_:"insert",option:"prime"}}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},"{(...)}":{"*":{action_:"text"}},"\\color{(...)}{(...)}1|\\color(...){(...)}2":{"*":{action_:"color-output"}},"\\color{(...)}0":{"*":{action_:"color0-output"}},"\\ce{(...)}":{"*":{action_:"ce"}},"\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"copy"}},else2:{"*":{action_:"copy"}}}),actions:{"color-output":function(t,e){return{type_:"color",color1:e[0],color2:a.go(e[1],"bd")}}}},oxidation:{transitions:a.createTransitions({empty:{"*":{}},"roman numeral":{"*":{action_:"roman-numeral"}},"${(...)}$|$(...)$":{"*":{action_:"tex-math"}},else:{"*":{action_:"copy"}}}),actions:{"roman-numeral":function(t,e){return{type_:"roman numeral",p1:e||""}}}},"tex-math":{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"\\ce{(...)}":{"*":{action_:["output","ce"]}},"{...}|\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"o="}},else:{"*":{action_:"o="}}}),actions:{output:function(t){if(t.o){var e={type_:"tex-math",p1:t.o};for(var n in t)delete t[n];return e}}}},"tex-math tight":{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"\\ce{(...)}":{"*":{action_:["output","ce"]}},"{...}|\\,|\\x{}{}|\\x{}|\\x":{"*":{action_:"o="}},"-|+":{"*":{action_:"tight operator"}},else:{"*":{action_:"o="}}}),actions:{"tight operator":function(t,e){t.o=(t.o||"")+"{"+e+"}"},output:function(t){if(t.o){var e={type_:"tex-math",p1:t.o};for(var n in t)delete t[n];return e}}}},"9,9":{transitions:a.createTransitions({empty:{"*":{}},",":{"*":{action_:"comma"}},else:{"*":{action_:"copy"}}}),actions:{comma:function(){return{type_:"commaDecimal"}}}},pu:{transitions:a.createTransitions({empty:{"*":{action_:"output"}},space$:{"*":{action_:["output","space"]}},"{[(|)]}":{"0|a":{action_:"copy"}},"(-)(9)^(-9)":{0:{action_:"number^",nextState:"a"}},"(-)(9.,9)(e)(99)":{0:{action_:"enumber",nextState:"a"}},space:{"0|a":{}},"pm-operator":{"0|a":{action_:{type_:"operator",option:"\\pm"},nextState:"0"}},operator:{"0|a":{action_:"copy",nextState:"0"}},"//":{d:{action_:"o=",nextState:"/"}},"/":{d:{action_:"o=",nextState:"/"}},"{...}|else":{"0|d":{action_:"d=",nextState:"d"},a:{action_:["space","d="],nextState:"d"},"/|q":{action_:"q=",nextState:"q"}}}),actions:{enumber:function(t,e){var n=[];return"+-"===e[0]||"+/-"===e[0]?n.push("\\pm "):e[0]&&n.push(e[0]),e[1]&&(a.concatArray(n,a.go(e[1],"pu-9,9")),e[2]&&(e[2].match(/[,.]/)?a.concatArray(n,a.go(e[2],"pu-9,9")):n.push(e[2])),e[3]=e[4]||e[3],e[3]&&(e[3]=e[3].trim(),"e"===e[3]||"*"===e[3].substr(0,1)?n.push({type_:"cdot"}):n.push({type_:"times"}))),e[3]&&n.push("10^{"+e[5]+"}"),n},"number^":function(t,e){var n=[];return"+-"===e[0]||"+/-"===e[0]?n.push("\\pm "):e[0]&&n.push(e[0]),a.concatArray(n,a.go(e[1],"pu-9,9")),n.push("^{"+e[2]+"}"),n},operator:function(t,e,n){return{type_:"operator",kind_:n||e}},space:function(){return{type_:"pu-space-1"}},output:function(t){var e,n=a.patterns.match_("{(...)}",t.d||"");n&&""===n.remainder&&(t.d=n.match_);var o=a.patterns.match_("{(...)}",t.q||"");if(o&&""===o.remainder&&(t.q=o.match_),t.d&&(t.d=t.d.replace(/\u00B0C|\^oC|\^{o}C/g,"{}^{\\circ}C"),t.d=t.d.replace(/\u00B0F|\^oF|\^{o}F/g,"{}^{\\circ}F")),t.q){t.q=t.q.replace(/\u00B0C|\^oC|\^{o}C/g,"{}^{\\circ}C"),t.q=t.q.replace(/\u00B0F|\^oF|\^{o}F/g,"{}^{\\circ}F");var r={d:a.go(t.d,"pu"),q:a.go(t.q,"pu")};"//"===t.o?e={type_:"pu-frac",p1:r.d,p2:r.q}:(e=r.d,r.d.length>1||r.q.length>1?e.push({type_:" / "}):e.push({type_:"/"}),a.concatArray(e,r.q))}else e=a.go(t.d,"pu-2");for(var i in t)delete t[i];return e}}},"pu-2":{transitions:a.createTransitions({empty:{"*":{action_:"output"}},"*":{"*":{action_:["output","cdot"],nextState:"0"}},"\\x":{"*":{action_:"rm="}},space:{"*":{action_:["output","space"],nextState:"0"}},"^{(...)}|^(-1)":{1:{action_:"^(-1)"}},"-9.,9":{0:{action_:"rm=",nextState:"0"},1:{action_:"^(-1)",nextState:"0"}},"{...}|else":{"*":{action_:"rm=",nextState:"1"}}}),actions:{cdot:function(){return{type_:"tight cdot"}},"^(-1)":function(t,e){t.rm+="^{"+e+"}"},space:function(){return{type_:"pu-space-2"}},output:function(t){var e=[];if(t.rm){var n=a.patterns.match_("{(...)}",t.rm||"");e=n&&""===n.remainder?a.go(n.match_,"pu"):{type_:"rm",p1:t.rm}}for(var o in t)delete t[o];return e}}},"pu-9,9":{transitions:a.createTransitions({empty:{0:{action_:"output-0"},o:{action_:"output-o"}},",":{0:{action_:["output-0","comma"],nextState:"o"}},".":{0:{action_:["output-0","copy"],nextState:"o"}},else:{"*":{action_:"text="}}}),actions:{comma:function(){return{type_:"commaDecimal"}},"output-0":function(t){var e=[];if(t.text_=t.text_||"",t.text_.length>4){var n=t.text_.length%3;0===n&&(n=3);for(var o=t.text_.length-3;o>0;o-=3)e.push(t.text_.substr(o,3)),e.push({type_:"1000 separator"});e.push(t.text_.substr(0,n)),e.reverse()}else e.push(t.text_);for(var a in t)delete t[a];return e},"output-o":function(t){var e=[];if(t.text_=t.text_||"",t.text_.length>4){for(var n=t.text_.length-3,o=0;o":case"\u2192":case"\u27f6":return"rightarrow";case"<-":return"leftarrow";case"<->":return"leftrightarrow";case"<--\x3e":return"rightleftarrows";case"<=>":case"\u21cc":return"rightleftharpoons";case"<=>>":return"rightequilibrium";case"<<=>":return"leftequilibrium";default:throw["MhchemBugT","mhchem bug T. Please report."]}},_getBond:function(t){switch(t){case"-":case"1":return"{-}";case"=":case"2":return"{=}";case"#":case"3":return"{\\equiv}";case"~":return"{\\tripledash}";case"~-":return"{\\mathrlap{\\raisebox{-.1em}{$-$}}\\raisebox{.1em}{$\\tripledash$}}";case"~=":case"~--":return"{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$\\tripledash$}}-}";case"-~-":return"{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$-$}}\\tripledash}";case"...":return"{{\\cdot}{\\cdot}{\\cdot}}";case"....":return"{{\\cdot}{\\cdot}{\\cdot}{\\cdot}}";case"->":return"{\\rightarrow}";case"<-":return"{\\leftarrow}";case"<":return"{<}";case">":return"{>}";default:throw["MhchemBugT","mhchem bug T. Please report."]}},_getOperator:function(t){switch(t){case"+":return" {}+{} ";case"-":return" {}-{} ";case"=":return" {}={} ";case"<":return" {}<{} ";case">":return" {}>{} ";case"<<":return" {}\\ll{} ";case">>":return" {}\\gg{} ";case"\\pm":return" {}\\pm{} ";case"\\approx":case"$\\approx$":return" {}\\approx{} ";case"v":case"(v)":return" \\downarrow{} ";case"^":case"(^)":return" \\uparrow{} ";default:throw["MhchemBugT","mhchem bug T. Please report."]}}}}(),a=a.default}()})); --------------------------------------------------------------------------------