├── 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 |
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 |
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 |
14 |
15 |
Count: {{ count }}
16 | {{ count | count_to_color }}
17 |
18 |
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 | 
143 |
144 | ### markdown 转图片(同时文本里面可以包括 html 图片)
145 |
146 | 
147 |
148 | ### 纯 html 转图片
149 |
150 | 
151 |
152 | ### jinja2 模板转图片
153 |
154 | 
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}()}));
--------------------------------------------------------------------------------