nonebot-plugin-parser 2.0.3__tar.gz → 2.0.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/PKG-INFO +2 -1
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/README.md +1 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/pyproject.toml +2 -2
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/config.py +7 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/base.py +2 -3
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/bilibili/__init__.py +20 -13
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/data.py +60 -27
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/nga.py +7 -8
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/youtube.py +2 -2
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/__init__.py +2 -3
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/base.py +5 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/common.py +157 -310
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/__init__.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/constants.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/download/__init__.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/download/task.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/download/ytdlp.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/exception.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/helper.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/matchers/__init__.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/matchers/filter.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/matchers/preprocess.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/__init__.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/acfun.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/bilibili/opus.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/bilibili/video.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/cookie.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/douyin/__init__.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/douyin/slides.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/douyin/video.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/kuaishou.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/tiktok.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/twitter.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/weibo.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/xiaohongshu.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/default.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/fonts/HYSongYunLangHeiW-1.ttf +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/templates/weibo.html.jinja +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/weibo.py +0 -0
- {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: nonebot-plugin-parser
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.5
|
|
4
4
|
Summary: NoneBot2 链接分享解析器自动解析, BV号/链接/小程序/卡片 | B站/抖音/快手/微博/小红书/youtube/tiktok/twitter/acfun
|
|
5
5
|
Keywords: nonebot,nonebot2,video,bilibili,youtube,tiktok,twitter,kuaishou,acfun,weibo,xiaohongshu,nga,douyin
|
|
6
6
|
Author: fllesser
|
|
@@ -153,6 +153,7 @@ Windows 参考(原项目推荐): https://www.jianshu.com/p/5015a477de3c
|
|
|
153
153
|
| parser_disabled_platforms | 否 | [] | 全局禁止的解析,示例 parser_disabled_platforms=["bilibili", "douyin"] 表示禁止了哔哩哔哩和抖, 请根据自己需求填写["bilibili", "douyin", "kuaishou", "twitter", "youtube", "acfun", "tiktok", "weibo", "xiaohongshu"] |
|
|
154
154
|
| parser_render_type | 否 | "common" | 渲染器类型,可选 "default"(无图片渲染), "common"(PIL 通用图片渲染), "htmlkit"(htmlkit) |
|
|
155
155
|
| parser_append_url | 否 | False | 是否在解析结果中附加原始URL |
|
|
156
|
+
| parser_custom_font | 否 | None | 自定义字体,如未指定则使用内置字体,需将字体文件放置于localstore 生成的插件 data 目录下,parser_custom_font 为字体文件名 |
|
|
156
157
|
|
|
157
158
|
## 🎉 使用
|
|
158
159
|
### 指令表
|
|
@@ -123,6 +123,7 @@ Windows 参考(原项目推荐): https://www.jianshu.com/p/5015a477de3c
|
|
|
123
123
|
| parser_disabled_platforms | 否 | [] | 全局禁止的解析,示例 parser_disabled_platforms=["bilibili", "douyin"] 表示禁止了哔哩哔哩和抖, 请根据自己需求填写["bilibili", "douyin", "kuaishou", "twitter", "youtube", "acfun", "tiktok", "weibo", "xiaohongshu"] |
|
|
124
124
|
| parser_render_type | 否 | "common" | 渲染器类型,可选 "default"(无图片渲染), "common"(PIL 通用图片渲染), "htmlkit"(htmlkit) |
|
|
125
125
|
| parser_append_url | 否 | False | 是否在解析结果中附加原始URL |
|
|
126
|
+
| parser_custom_font | 否 | None | 自定义字体,如未指定则使用内置字体,需将字体文件放置于localstore 生成的插件 data 目录下,parser_custom_font 为字体文件名 |
|
|
126
127
|
|
|
127
128
|
## 🎉 使用
|
|
128
129
|
### 指令表
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nonebot-plugin-parser"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.5"
|
|
4
4
|
description = "NoneBot2 链接分享解析器自动解析, BV号/链接/小程序/卡片 | B站/抖音/快手/微博/小红书/youtube/tiktok/twitter/acfun"
|
|
5
5
|
authors = [{ "name" = "fllesser", "email" = "fllessive@gmail.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -185,7 +185,7 @@ build-backend = "uv_build"
|
|
|
185
185
|
|
|
186
186
|
|
|
187
187
|
[tool.bumpversion]
|
|
188
|
-
current_version = "2.0.
|
|
188
|
+
current_version = "2.0.5"
|
|
189
189
|
commit = true
|
|
190
190
|
message = "🔖 release: bump vesion from {current_version} to {new_version}"
|
|
191
191
|
tag = true
|
{nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/config.py
RENAMED
|
@@ -51,6 +51,8 @@ class Config(BaseModel):
|
|
|
51
51
|
"""B站视频编码"""
|
|
52
52
|
parser_render_type: RenderType = RenderType.common
|
|
53
53
|
"""Renderer 类型"""
|
|
54
|
+
parser_custom_font: str | None = None
|
|
55
|
+
"""自定义字体"""
|
|
54
56
|
|
|
55
57
|
@property
|
|
56
58
|
def nickname(self) -> str:
|
|
@@ -127,6 +129,11 @@ class Config(BaseModel):
|
|
|
127
129
|
"""是否在解析结果中附加原始URL"""
|
|
128
130
|
return self.parser_append_url
|
|
129
131
|
|
|
132
|
+
@property
|
|
133
|
+
def custom_font(self) -> Path | None:
|
|
134
|
+
"""自定义字体"""
|
|
135
|
+
return (self.data_dir / self.parser_custom_font) if self.parser_custom_font else None
|
|
136
|
+
|
|
130
137
|
|
|
131
138
|
pconfig: Config = get_plugin_config(Config)
|
|
132
139
|
"""配置"""
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
from asyncio import Task
|
|
5
|
-
from collections.abc import Sequence
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
import re
|
|
8
7
|
from typing import ClassVar
|
|
@@ -116,7 +115,7 @@ class BaseParser(ABC):
|
|
|
116
115
|
video_task = url_or_task
|
|
117
116
|
return VideoContent(video_task, cover_task, duration)
|
|
118
117
|
|
|
119
|
-
def create_image_contents(self, image_urls:
|
|
118
|
+
def create_image_contents(self, image_urls: list[str]):
|
|
120
119
|
"""创建图片内容列表"""
|
|
121
120
|
from ..download import DOWNLOADER
|
|
122
121
|
from .data import ImageContent
|
|
@@ -124,7 +123,7 @@ class BaseParser(ABC):
|
|
|
124
123
|
img_tasks = [DOWNLOADER.download_img(url, ext_headers=self.headers) for url in image_urls]
|
|
125
124
|
return [ImageContent(task) for task in img_tasks]
|
|
126
125
|
|
|
127
|
-
def create_dynamic_contents(self, dynamic_urls:
|
|
126
|
+
def create_dynamic_contents(self, dynamic_urls: list[str]):
|
|
128
127
|
"""创建动态内容列表"""
|
|
129
128
|
from ..download import DOWNLOADER
|
|
130
129
|
from .data import DynamicContent
|
|
@@ -66,25 +66,25 @@ class BilibiliParser(BaseParser):
|
|
|
66
66
|
if "b23.tv" in url or "bili2233.cn" in url:
|
|
67
67
|
url = await self.get_redirect_url(url, self.headers)
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
# 链接中是否包含BV,av号
|
|
71
|
-
if video_id:
|
|
72
|
-
if video_id.isdigit():
|
|
73
|
-
avid = int(video_id)
|
|
74
|
-
else:
|
|
75
|
-
bvid = video_id
|
|
76
|
-
else:
|
|
69
|
+
if not video_id:
|
|
77
70
|
if _matched := re.search(r"(BV[\dA-Za-z]{10})[^?]*?(?:\?[^#]*?p=(\d{1,3}))?", url):
|
|
78
|
-
|
|
71
|
+
video_id = _matched.group(1)
|
|
79
72
|
page_num = _matched.group(2)
|
|
80
73
|
elif _matched := re.search(r"av(\d{6,})[^?]*?(?:\?[^#]*?p=(\d{1,3}))?", url):
|
|
81
|
-
|
|
74
|
+
video_id = _matched.group(1)
|
|
82
75
|
page_num = _matched.group(2)
|
|
83
76
|
else:
|
|
84
77
|
return await self.parse_others(url)
|
|
85
78
|
|
|
79
|
+
avid, bvid = None, None
|
|
86
80
|
page_num = int(page_num) if page_num and page_num.isdigit() else 1
|
|
87
81
|
|
|
82
|
+
# 链接中是否包含BV,av号
|
|
83
|
+
if video_id.isdigit():
|
|
84
|
+
avid = int(video_id)
|
|
85
|
+
else:
|
|
86
|
+
bvid = video_id
|
|
87
|
+
|
|
88
88
|
# 解析视频信息
|
|
89
89
|
return await self.parse_video(bvid=bvid, avid=avid, page_num=page_num)
|
|
90
90
|
|
|
@@ -110,6 +110,12 @@ class BilibiliParser(BaseParser):
|
|
|
110
110
|
# 转换为 msgspec struct
|
|
111
111
|
video_info = msgspec.convert(await video.get_info(), VideoInfo)
|
|
112
112
|
|
|
113
|
+
# 获取简介
|
|
114
|
+
text = f"简介: {video_info.desc}" if video_info.desc else None
|
|
115
|
+
|
|
116
|
+
# up
|
|
117
|
+
author = self.create_author(video_info.owner.name, video_info.owner.face)
|
|
118
|
+
|
|
113
119
|
# 处理分 p
|
|
114
120
|
page_idx, title, duration, timestamp, cover_url = video_info.extract_info_with_page(page_num)
|
|
115
121
|
|
|
@@ -140,14 +146,15 @@ class BilibiliParser(BaseParser):
|
|
|
140
146
|
return await DOWNLOADER.streamd(v_url, file_name=output_path.name, ext_headers=self.headers)
|
|
141
147
|
|
|
142
148
|
video_task = asyncio.create_task(download_video())
|
|
149
|
+
video_content = self.create_video_content(video_task, cover_url, duration)
|
|
143
150
|
|
|
144
151
|
return self.result(
|
|
145
152
|
url=url,
|
|
146
153
|
title=title,
|
|
147
154
|
timestamp=timestamp,
|
|
148
|
-
text=
|
|
149
|
-
author=
|
|
150
|
-
contents=[
|
|
155
|
+
text=text,
|
|
156
|
+
author=author,
|
|
157
|
+
contents=[video_content],
|
|
151
158
|
extra={"info": ai_summary},
|
|
152
159
|
)
|
|
153
160
|
|
|
@@ -5,7 +5,14 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
def repr_path_task(path_task: Path | Task[Path]) -> str:
|
|
9
|
+
if isinstance(path_task, Path):
|
|
10
|
+
return f"path={path_task}"
|
|
11
|
+
else:
|
|
12
|
+
return f"task={path_task.get_name()}, done={path_task.done()}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(repr=False)
|
|
9
16
|
class MediaContent:
|
|
10
17
|
path_task: Path | Task[Path]
|
|
11
18
|
|
|
@@ -15,15 +22,19 @@ class MediaContent:
|
|
|
15
22
|
self.path_task = await self.path_task
|
|
16
23
|
return self.path_task
|
|
17
24
|
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
prefix = self.__class__.__name__
|
|
27
|
+
return f"{prefix}({repr_path_task(self.path_task)})"
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
|
|
30
|
+
@dataclass(repr=False)
|
|
20
31
|
class AudioContent(MediaContent):
|
|
21
32
|
"""音频内容"""
|
|
22
33
|
|
|
23
34
|
duration: float = 0.0
|
|
24
35
|
|
|
25
36
|
|
|
26
|
-
@dataclass
|
|
37
|
+
@dataclass(repr=False)
|
|
27
38
|
class VideoContent(MediaContent):
|
|
28
39
|
"""视频内容"""
|
|
29
40
|
|
|
@@ -47,21 +58,21 @@ class VideoContent(MediaContent):
|
|
|
47
58
|
return f"时长: {minutes}:{seconds:02d}"
|
|
48
59
|
|
|
49
60
|
|
|
50
|
-
@dataclass
|
|
61
|
+
@dataclass(repr=False)
|
|
51
62
|
class ImageContent(MediaContent):
|
|
52
63
|
"""图片内容"""
|
|
53
64
|
|
|
54
65
|
pass
|
|
55
66
|
|
|
56
67
|
|
|
57
|
-
@dataclass
|
|
68
|
+
@dataclass(repr=False)
|
|
58
69
|
class DynamicContent(MediaContent):
|
|
59
70
|
"""动态内容 视频格式 后续转 gif"""
|
|
60
71
|
|
|
61
72
|
gif_path: Path | None = None
|
|
62
73
|
|
|
63
74
|
|
|
64
|
-
@dataclass
|
|
75
|
+
@dataclass(repr=False)
|
|
65
76
|
class GraphicsContent(MediaContent):
|
|
66
77
|
"""图文内容"""
|
|
67
78
|
|
|
@@ -78,7 +89,7 @@ class Platform:
|
|
|
78
89
|
""" 平台显示名称 """
|
|
79
90
|
|
|
80
91
|
|
|
81
|
-
@dataclass
|
|
92
|
+
@dataclass(repr=False)
|
|
82
93
|
class Author:
|
|
83
94
|
"""作者信息"""
|
|
84
95
|
|
|
@@ -97,25 +108,33 @@ class Author:
|
|
|
97
108
|
self.avatar = await self.avatar
|
|
98
109
|
return self.avatar
|
|
99
110
|
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
repr = f"Author(name={self.name}"
|
|
113
|
+
if self.avatar:
|
|
114
|
+
repr += f", avatar_{repr_path_task(self.avatar)}"
|
|
115
|
+
if self.description:
|
|
116
|
+
repr += f", description={self.description}"
|
|
117
|
+
return repr + ")"
|
|
100
118
|
|
|
101
|
-
|
|
119
|
+
|
|
120
|
+
@dataclass(repr=False)
|
|
102
121
|
class ParseResult:
|
|
103
122
|
"""完整的解析结果"""
|
|
104
123
|
|
|
105
124
|
platform: Platform
|
|
106
125
|
"""平台信息"""
|
|
107
|
-
|
|
126
|
+
author: Author | None = None
|
|
127
|
+
"""作者信息"""
|
|
128
|
+
title: str | None = None
|
|
108
129
|
"""标题"""
|
|
109
|
-
text: str =
|
|
130
|
+
text: str | None = None
|
|
110
131
|
"""文本内容"""
|
|
111
|
-
contents: list[MediaContent] = field(default_factory=list)
|
|
112
|
-
"""内容列表,主体以外的内容"""
|
|
113
132
|
timestamp: int | None = None
|
|
114
133
|
"""发布时间戳, 秒"""
|
|
115
134
|
url: str | None = None
|
|
116
135
|
"""来源链接"""
|
|
117
|
-
|
|
118
|
-
"""
|
|
136
|
+
contents: list[MediaContent] = field(default_factory=list)
|
|
137
|
+
"""媒体内容"""
|
|
119
138
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
120
139
|
"""额外信息"""
|
|
121
140
|
repost: "ParseResult | None" = None
|
|
@@ -123,6 +142,7 @@ class ParseResult:
|
|
|
123
142
|
|
|
124
143
|
@property
|
|
125
144
|
def header(self) -> str:
|
|
145
|
+
"""头信息 仅用于 default render"""
|
|
126
146
|
header = self.platform.display_name
|
|
127
147
|
if self.author:
|
|
128
148
|
header += f" @{self.author.name}"
|
|
@@ -131,16 +151,16 @@ class ParseResult:
|
|
|
131
151
|
return header
|
|
132
152
|
|
|
133
153
|
@property
|
|
134
|
-
def display_url(self) -> str:
|
|
135
|
-
return f"链接: {self.url}" if self.url else
|
|
154
|
+
def display_url(self) -> str | None:
|
|
155
|
+
return f"链接: {self.url}" if self.url else None
|
|
136
156
|
|
|
137
157
|
@property
|
|
138
|
-
def repost_display_url(self) -> str:
|
|
139
|
-
return f"原帖: {self.repost.url}" if self.repost and self.repost.url else
|
|
158
|
+
def repost_display_url(self) -> str | None:
|
|
159
|
+
return f"原帖: {self.repost.url}" if self.repost and self.repost.url else None
|
|
140
160
|
|
|
141
161
|
@property
|
|
142
|
-
def extra_info(self) -> str:
|
|
143
|
-
return self.extra.get("info"
|
|
162
|
+
def extra_info(self) -> str | None:
|
|
163
|
+
return self.extra.get("info")
|
|
144
164
|
|
|
145
165
|
@property
|
|
146
166
|
def video_contents(self) -> list[VideoContent]:
|
|
@@ -164,16 +184,29 @@ class ParseResult:
|
|
|
164
184
|
|
|
165
185
|
@property
|
|
166
186
|
async def cover_path(self) -> Path | None:
|
|
187
|
+
"""获取封面路径"""
|
|
167
188
|
for cont in self.contents:
|
|
168
189
|
if isinstance(cont, VideoContent):
|
|
169
190
|
return await cont.get_cover_path()
|
|
170
191
|
return None
|
|
171
192
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
@property
|
|
194
|
+
def formartted_datetime(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None:
|
|
195
|
+
"""格式化时间戳"""
|
|
196
|
+
return datetime.fromtimestamp(self.timestamp).strftime(fmt) if self.timestamp is not None else None
|
|
197
|
+
|
|
198
|
+
def __repr__(self) -> str:
|
|
199
|
+
return (
|
|
200
|
+
f"\nplatform: {self.platform}\n"
|
|
201
|
+
f"title: {self.title}\n"
|
|
202
|
+
f"timestamp: {self.timestamp}\n"
|
|
203
|
+
f"author: {self.author}\n"
|
|
204
|
+
f"text: {self.text}\n"
|
|
205
|
+
f"contents: {self.contents}\n"
|
|
206
|
+
f"url: {self.url}\n"
|
|
207
|
+
f"extra: {self.extra}\n"
|
|
208
|
+
f"repost: {self.repost}\n"
|
|
209
|
+
)
|
|
177
210
|
|
|
178
211
|
|
|
179
212
|
from dataclasses import dataclass, field
|
|
@@ -181,8 +214,8 @@ from typing import Any, TypedDict
|
|
|
181
214
|
|
|
182
215
|
|
|
183
216
|
class ParseResultKwargs(TypedDict, total=False):
|
|
184
|
-
title: str
|
|
185
|
-
text: str
|
|
217
|
+
title: str | None
|
|
218
|
+
text: str | None
|
|
186
219
|
contents: list[MediaContent]
|
|
187
220
|
timestamp: int | None
|
|
188
221
|
url: str | None
|
{nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/nga.py
RENAMED
|
@@ -10,8 +10,7 @@ from bs4 import BeautifulSoup, Tag
|
|
|
10
10
|
import httpx
|
|
11
11
|
|
|
12
12
|
from ..exception import ParseException
|
|
13
|
-
from .base import BaseParser
|
|
14
|
-
from .data import Author, ParseResult, Platform
|
|
13
|
+
from .base import BaseParser, Platform
|
|
15
14
|
|
|
16
15
|
|
|
17
16
|
class NGAParser(BaseParser):
|
|
@@ -45,14 +44,14 @@ class NGAParser(BaseParser):
|
|
|
45
44
|
return f"https://nga.178.com/read.php?tid={tid}"
|
|
46
45
|
|
|
47
46
|
@override
|
|
48
|
-
async def parse(self, matched: re.Match[str])
|
|
47
|
+
async def parse(self, matched: re.Match[str]):
|
|
49
48
|
"""解析 URL 获取内容信息并下载资源
|
|
50
49
|
|
|
51
50
|
Args:
|
|
52
51
|
matched: 正则表达式匹配对象,由平台对应的模式匹配得到
|
|
53
52
|
|
|
54
53
|
Returns:
|
|
55
|
-
ParseResult:
|
|
54
|
+
ParseResult: 解析结果
|
|
56
55
|
|
|
57
56
|
Raises:
|
|
58
57
|
ParseException: 解析失败时抛出
|
|
@@ -103,7 +102,7 @@ class NGAParser(BaseParser):
|
|
|
103
102
|
soup = BeautifulSoup(html, "html.parser")
|
|
104
103
|
|
|
105
104
|
# 提取 title - 从 postsubject0
|
|
106
|
-
title =
|
|
105
|
+
title = None
|
|
107
106
|
title_tag = soup.find(id="postsubject0")
|
|
108
107
|
if title_tag and isinstance(title_tag, Tag):
|
|
109
108
|
title = title_tag.get_text(strip=True)
|
|
@@ -130,7 +129,7 @@ class NGAParser(BaseParser):
|
|
|
130
129
|
except (json.JSONDecodeError, KeyError):
|
|
131
130
|
# JSON 解析失败或数据结构不符合预期,保持 author 为 None
|
|
132
131
|
pass
|
|
133
|
-
|
|
132
|
+
author = self.create_author(author) if author else None
|
|
134
133
|
# 提取时间 - 从第一个帖子的 postdate0
|
|
135
134
|
timestamp = None
|
|
136
135
|
time_tag = soup.find(id="postdate0")
|
|
@@ -139,7 +138,7 @@ class NGAParser(BaseParser):
|
|
|
139
138
|
timestamp = int(time.mktime(time.strptime(timestr, "%Y-%m-%d %H:%M")))
|
|
140
139
|
|
|
141
140
|
# 提取文本 - postcontent0
|
|
142
|
-
text =
|
|
141
|
+
text = None
|
|
143
142
|
content_tag = soup.find(id="postcontent0")
|
|
144
143
|
if content_tag and isinstance(content_tag, Tag):
|
|
145
144
|
text = content_tag.get_text("\n", strip=True)
|
|
@@ -150,7 +149,7 @@ class NGAParser(BaseParser):
|
|
|
150
149
|
title=title,
|
|
151
150
|
text=text,
|
|
152
151
|
url=url,
|
|
153
|
-
author=
|
|
152
|
+
author=author,
|
|
154
153
|
timestamp=timestamp,
|
|
155
154
|
)
|
|
156
155
|
|
|
@@ -50,7 +50,7 @@ class YouTubeParser(BaseParser):
|
|
|
50
50
|
video = YTDLP_DOWNLOADER.download_video(url, self.cookies_file)
|
|
51
51
|
contents.append(self.create_video_content(video, video_info.thumbnail, video_info.duration))
|
|
52
52
|
else:
|
|
53
|
-
contents.
|
|
53
|
+
contents.extend(self.create_image_contents([video_info.thumbnail]))
|
|
54
54
|
|
|
55
55
|
return self.result(
|
|
56
56
|
title=video_info.title,
|
|
@@ -73,7 +73,7 @@ class YouTubeParser(BaseParser):
|
|
|
73
73
|
author = await self._fetch_author_info(video_info.channel_id)
|
|
74
74
|
|
|
75
75
|
contents = []
|
|
76
|
-
contents.
|
|
76
|
+
contents.extend(self.create_image_contents([video_info.thumbnail]))
|
|
77
77
|
|
|
78
78
|
if video_info.duration <= pconfig.duration_maximum:
|
|
79
79
|
audio_task = YTDLP_DOWNLOADER.download_audio(url, self.cookies_file)
|
|
@@ -4,6 +4,7 @@ from itertools import chain
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any, ClassVar
|
|
6
6
|
|
|
7
|
+
from ..config import pconfig
|
|
7
8
|
from ..exception import DownloadException, DownloadLimitException, ZeroSizeException
|
|
8
9
|
from ..helper import ForwardNodeInner, UniHelper, UniMessage
|
|
9
10
|
from ..parsers import ParseResult
|
|
@@ -74,3 +75,7 @@ class BaseRenderer(ABC):
|
|
|
74
75
|
message = f"{failed_count} 项媒体下载失败"
|
|
75
76
|
yield UniMessage(message)
|
|
76
77
|
raise DownloadException(message)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def append_url(self) -> bool:
|
|
81
|
+
return pconfig.append_url
|
|
@@ -3,17 +3,19 @@ from pathlib import Path
|
|
|
3
3
|
from typing import Any, ClassVar
|
|
4
4
|
from typing_extensions import override
|
|
5
5
|
|
|
6
|
+
from nonebot import logger
|
|
6
7
|
from PIL import Image, ImageDraw, ImageFont
|
|
7
8
|
|
|
8
|
-
from ..config import pconfig
|
|
9
9
|
from .base import BaseRenderer, ParseResult, UniHelper, UniMessage
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class CommonRenderer(BaseRenderer):
|
|
13
13
|
"""统一的渲染器,将解析结果转换为消息"""
|
|
14
14
|
|
|
15
|
+
__slots__ = ("font_path", "fonts")
|
|
16
|
+
|
|
15
17
|
# 卡片配置常量
|
|
16
|
-
PADDING =
|
|
18
|
+
PADDING = 25
|
|
17
19
|
AVATAR_SIZE = 80
|
|
18
20
|
AVATAR_TEXT_GAP = 15 # 头像和文字之间的间距
|
|
19
21
|
MAX_COVER_WIDTH = 1000
|
|
@@ -42,8 +44,6 @@ class CommonRenderer(BaseRenderer):
|
|
|
42
44
|
REPOST_BG_COLOR = (247, 247, 247) # 转发背景色
|
|
43
45
|
REPOST_BORDER_COLOR = (230, 230, 230) # 转发边框色
|
|
44
46
|
REPOST_PADDING = 12 # 转发内容内边距
|
|
45
|
-
REPOST_AVATAR_SIZE = 32 # 转发头像尺寸
|
|
46
|
-
REPOST_AVATAR_GAP = 8 # 转发头像和文字间距
|
|
47
47
|
|
|
48
48
|
# 图片处理配置
|
|
49
49
|
MIN_COVER_WIDTH = 300 # 最小封面宽度
|
|
@@ -72,20 +72,27 @@ class CommonRenderer(BaseRenderer):
|
|
|
72
72
|
FONT_SIZES: ClassVar[dict[str, int]] = {"name": 28, "title": 30, "text": 24, "extra": 24}
|
|
73
73
|
LINE_HEIGHTS: ClassVar[dict[str, int]] = {"name": 32, "title": 36, "text": 28, "extra": 28}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
DEFAULT_FONT_PATH: ClassVar[Path] = Path(__file__).parent / "fonts" / "HYSongYunLangHeiW-1.ttf"
|
|
76
|
+
|
|
77
|
+
def __init__(self, font_path: Path | None = None):
|
|
78
|
+
self.font_path: Path = self.DEFAULT_FONT_PATH
|
|
79
|
+
|
|
80
|
+
def load_font(self, font_path: Path | None = None):
|
|
81
|
+
if font_path is not None and font_path.exists():
|
|
82
|
+
self.font_path = font_path
|
|
83
|
+
self.fonts: dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont] = {
|
|
84
|
+
name: ImageFont.truetype(self.font_path, size) for name, size in self.FONT_SIZES.items()
|
|
85
|
+
}
|
|
86
|
+
logger.success(f"加载字体「{self.font_path.name}」成功")
|
|
80
87
|
|
|
81
88
|
@override
|
|
82
89
|
async def render_messages(self, result: ParseResult):
|
|
83
90
|
# 生成图片卡片
|
|
84
91
|
if image_raw := await self.draw_common_image(result):
|
|
85
92
|
msg = UniMessage(UniHelper.img_seg(raw=image_raw))
|
|
86
|
-
if
|
|
93
|
+
if self.append_url:
|
|
87
94
|
urls = (result.display_url, result.repost_display_url)
|
|
88
|
-
msg += "\n".join(urls)
|
|
95
|
+
msg += "\n".join(url for url in urls if url)
|
|
89
96
|
yield msg
|
|
90
97
|
|
|
91
98
|
# 媒体内容
|
|
@@ -101,40 +108,62 @@ class CommonRenderer(BaseRenderer):
|
|
|
101
108
|
Returns:
|
|
102
109
|
PNG 图片的字节数据,如果没有足够的内容则返回 None
|
|
103
110
|
"""
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
# 调用内部方法生成图片
|
|
112
|
+
image = await self._create_card_image(result)
|
|
113
|
+
if not image:
|
|
114
|
+
return None
|
|
107
115
|
|
|
108
|
-
#
|
|
109
|
-
|
|
116
|
+
# 将图片转换为字节
|
|
117
|
+
output = BytesIO()
|
|
118
|
+
image.save(output, format="PNG")
|
|
119
|
+
return output.getvalue()
|
|
110
120
|
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
async def _create_card_image(
|
|
122
|
+
self, result: ParseResult, bg_color: tuple[int, int, int] | None = None, apply_min_cover_size: bool = True
|
|
123
|
+
) -> Image.Image | None:
|
|
124
|
+
"""创建卡片图片(内部方法,用于递归调用)
|
|
113
125
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
Args:
|
|
127
|
+
result: 解析结果
|
|
128
|
+
bg_color: 背景颜色,默认使用 BG_COLOR
|
|
129
|
+
apply_min_cover_size: 是否对封面应用最小尺寸限制,转发内容不需要
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
PIL Image 对象,如果没有足够的内容则返回 None
|
|
133
|
+
"""
|
|
134
|
+
# 使用预加载的字体
|
|
135
|
+
|
|
136
|
+
# 先确定固定的卡片宽度和内容区域宽度
|
|
137
|
+
card_width = max(self.DEFAULT_CARD_WIDTH, self.MIN_CARD_WIDTH)
|
|
119
138
|
content_width = card_width - 2 * self.PADDING
|
|
120
139
|
|
|
140
|
+
# 加载并处理封面,传入内容区域宽度以确保封面不超过内容区域
|
|
141
|
+
cover_img = self._load_and_resize_cover(
|
|
142
|
+
await result.cover_path, content_width=content_width, apply_min_size=apply_min_cover_size
|
|
143
|
+
)
|
|
144
|
+
|
|
121
145
|
# 计算各部分内容的高度
|
|
122
|
-
heights = await self._calculate_sections(result, cover_img, content_width
|
|
146
|
+
heights = await self._calculate_sections(result, cover_img, content_width)
|
|
123
147
|
|
|
124
148
|
# 计算总高度
|
|
125
149
|
card_height = sum(h for _, h, _ in heights) + self.PADDING * 2 + self.SECTION_SPACING * (len(heights) - 1)
|
|
150
|
+
# 创建画布并绘制(使用指定的背景颜色,或默认背景颜色)
|
|
151
|
+
background_color = bg_color if bg_color is not None else self.BG_COLOR
|
|
152
|
+
image = Image.new("RGB", (card_width, card_height), background_color)
|
|
153
|
+
self._draw_sections(image, heights, card_width)
|
|
126
154
|
|
|
127
|
-
|
|
128
|
-
image = Image.new("RGB", (card_width, card_height), self.BG_COLOR)
|
|
129
|
-
self._draw_sections(image, heights, card_width, fonts)
|
|
155
|
+
return image
|
|
130
156
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
def _load_and_resize_cover(
|
|
158
|
+
self, cover_path: Path | None, content_width: int, apply_min_size: bool = True
|
|
159
|
+
) -> Image.Image | None:
|
|
160
|
+
"""加载并调整封面尺寸
|
|
135
161
|
|
|
136
|
-
|
|
137
|
-
|
|
162
|
+
Args:
|
|
163
|
+
cover_path: 封面路径
|
|
164
|
+
content_width: 内容区域宽度,封面会缩放到此宽度以确保左右padding一致
|
|
165
|
+
apply_min_size: 是否应用最小尺寸限制(转发内容不需要)
|
|
166
|
+
"""
|
|
138
167
|
if not cover_path or not cover_path.exists():
|
|
139
168
|
return None
|
|
140
169
|
|
|
@@ -145,24 +174,35 @@ class CommonRenderer(BaseRenderer):
|
|
|
145
174
|
if cover_img.mode not in ("RGB", "RGBA"):
|
|
146
175
|
cover_img = cover_img.convert("RGB")
|
|
147
176
|
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
width_ratio = self.MAX_COVER_WIDTH / cover_img.width
|
|
151
|
-
height_ratio = self.MAX_COVER_HEIGHT / cover_img.height
|
|
152
|
-
scale_ratio = min(width_ratio, height_ratio)
|
|
177
|
+
# 封面宽度应该等于内容区域宽度,以确保左右padding一致
|
|
178
|
+
target_width = content_width
|
|
153
179
|
|
|
154
|
-
|
|
180
|
+
# 计算缩放比例(保持宽高比)
|
|
181
|
+
if cover_img.width != target_width:
|
|
182
|
+
scale_ratio = target_width / cover_img.width
|
|
183
|
+
new_width = target_width
|
|
155
184
|
new_height = int(cover_img.height * scale_ratio)
|
|
156
|
-
cover_img = cover_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
157
185
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
186
|
+
# 检查高度是否超过最大限制
|
|
187
|
+
if new_height > self.MAX_COVER_HEIGHT:
|
|
188
|
+
# 如果高度超限,按高度重新计算
|
|
189
|
+
scale_ratio = self.MAX_COVER_HEIGHT / new_height
|
|
190
|
+
new_height = self.MAX_COVER_HEIGHT
|
|
191
|
+
new_width = int(new_width * scale_ratio)
|
|
192
|
+
|
|
193
|
+
# 如果是主内容且高度太小,需要放大(但不超过最大高度)
|
|
194
|
+
if apply_min_size and new_height < self.MIN_COVER_HEIGHT:
|
|
195
|
+
min_height = min(self.MIN_COVER_HEIGHT, self.MAX_COVER_HEIGHT)
|
|
196
|
+
if new_height < min_height:
|
|
197
|
+
scale_ratio = min_height / new_height
|
|
198
|
+
new_height = min_height
|
|
199
|
+
new_width = int(new_width * scale_ratio)
|
|
200
|
+
# 再次确保宽度不超过内容区域
|
|
201
|
+
if new_width > content_width:
|
|
202
|
+
scale_ratio = content_width / new_width
|
|
203
|
+
new_width = content_width
|
|
204
|
+
new_height = int(new_height * scale_ratio)
|
|
163
205
|
|
|
164
|
-
new_width = int(cover_img.width * scale_ratio)
|
|
165
|
-
new_height = int(cover_img.height * scale_ratio)
|
|
166
206
|
cover_img = cover_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
167
207
|
|
|
168
208
|
return cover_img
|
|
@@ -205,20 +245,20 @@ class CommonRenderer(BaseRenderer):
|
|
|
205
245
|
return None
|
|
206
246
|
|
|
207
247
|
async def _calculate_sections(
|
|
208
|
-
self, result: ParseResult, cover_img: Image.Image | None, content_width: int
|
|
248
|
+
self, result: ParseResult, cover_img: Image.Image | None, content_width: int
|
|
209
249
|
) -> list[tuple[str, int, Any]]:
|
|
210
250
|
"""计算各部分内容的高度和数据"""
|
|
211
251
|
heights = []
|
|
212
252
|
|
|
213
253
|
# 1. Header 部分
|
|
214
254
|
if result.author:
|
|
215
|
-
header_data = await self._calculate_header_section(result, content_width
|
|
255
|
+
header_data = await self._calculate_header_section(result, content_width)
|
|
216
256
|
if header_data:
|
|
217
257
|
heights.append(("header", header_data["height"], header_data))
|
|
218
258
|
|
|
219
259
|
# 2. 标题部分
|
|
220
260
|
if result.title:
|
|
221
|
-
title_lines = self._wrap_text(result.title, content_width, fonts["title"])
|
|
261
|
+
title_lines = self._wrap_text(result.title, content_width, self.fonts["title"])
|
|
222
262
|
title_height = len(title_lines) * self.LINE_HEIGHTS["title"]
|
|
223
263
|
heights.append(("title", title_height, title_lines))
|
|
224
264
|
|
|
@@ -233,25 +273,25 @@ class CommonRenderer(BaseRenderer):
|
|
|
233
273
|
|
|
234
274
|
# 4. 文本内容
|
|
235
275
|
if result.text:
|
|
236
|
-
text_lines = self._wrap_text(result.text, content_width, fonts["text"])
|
|
276
|
+
text_lines = self._wrap_text(result.text, content_width, self.fonts["text"])
|
|
237
277
|
text_height = len(text_lines) * self.LINE_HEIGHTS["text"]
|
|
238
278
|
heights.append(("text", text_height, text_lines))
|
|
239
279
|
|
|
240
280
|
# 5. 额外信息
|
|
241
281
|
if result.extra_info:
|
|
242
|
-
extra_lines = self._wrap_text(result.extra_info, content_width, fonts["extra"])
|
|
282
|
+
extra_lines = self._wrap_text(result.extra_info, content_width, self.fonts["extra"])
|
|
243
283
|
extra_height = len(extra_lines) * self.LINE_HEIGHTS["extra"]
|
|
244
284
|
heights.append(("extra", extra_height, extra_lines))
|
|
245
285
|
|
|
246
286
|
# 6. 转发内容
|
|
247
287
|
if result.repost:
|
|
248
|
-
repost_data = await self._calculate_repost_section(result.repost, content_width
|
|
288
|
+
repost_data = await self._calculate_repost_section(result.repost, content_width)
|
|
249
289
|
if repost_data:
|
|
250
290
|
heights.append(("repost", repost_data["height"], repost_data))
|
|
251
291
|
|
|
252
292
|
return heights
|
|
253
293
|
|
|
254
|
-
async def _calculate_header_section(self, result: ParseResult, content_width: int
|
|
294
|
+
async def _calculate_header_section(self, result: ParseResult, content_width: int) -> dict | None:
|
|
255
295
|
"""计算 header 部分的高度和内容"""
|
|
256
296
|
if not result.author:
|
|
257
297
|
return None
|
|
@@ -263,11 +303,11 @@ class CommonRenderer(BaseRenderer):
|
|
|
263
303
|
text_area_width = content_width - (self.AVATAR_SIZE + self.AVATAR_TEXT_GAP)
|
|
264
304
|
|
|
265
305
|
# 发布者名称
|
|
266
|
-
name_lines = self._wrap_text(result.author.name, text_area_width, fonts["name"])
|
|
306
|
+
name_lines = self._wrap_text(result.author.name, text_area_width, self.fonts["name"])
|
|
267
307
|
|
|
268
308
|
# 时间
|
|
269
|
-
time_text = result.
|
|
270
|
-
time_lines = self._wrap_text(time_text, text_area_width, fonts["extra"]) if time_text else []
|
|
309
|
+
time_text = result.formartted_datetime
|
|
310
|
+
time_lines = self._wrap_text(time_text, text_area_width, self.fonts["extra"]) if time_text else []
|
|
271
311
|
|
|
272
312
|
# 计算 header 高度(取头像和文字中较大者)
|
|
273
313
|
text_height = len(name_lines) * self.LINE_HEIGHTS["name"]
|
|
@@ -283,28 +323,24 @@ class CommonRenderer(BaseRenderer):
|
|
|
283
323
|
"text_height": text_height,
|
|
284
324
|
}
|
|
285
325
|
|
|
286
|
-
async def _calculate_repost_section(self, repost: ParseResult, content_width: int
|
|
287
|
-
"""
|
|
326
|
+
async def _calculate_repost_section(self, repost: ParseResult, content_width: int) -> dict | None:
|
|
327
|
+
"""计算转发内容的高度和内容(递归调用绘制方法)"""
|
|
288
328
|
if not repost:
|
|
289
329
|
return None
|
|
290
330
|
|
|
291
|
-
#
|
|
292
|
-
|
|
293
|
-
|
|
331
|
+
# 递归调用内部方法,生成转发内容的完整卡片(使用转发背景颜色,不强制放大封面)
|
|
332
|
+
repost_image = await self._create_card_image(repost, bg_color=self.REPOST_BG_COLOR, apply_min_cover_size=False)
|
|
333
|
+
if not repost_image:
|
|
334
|
+
return None
|
|
294
335
|
|
|
295
|
-
#
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
sum(h for _, h, _ in repost_heights) + self.PADDING * 2 + self.SECTION_SPACING * (len(repost_heights) - 1)
|
|
300
|
-
)
|
|
336
|
+
# 缩放图片
|
|
337
|
+
scaled_width = int(repost_image.width * self.REPOST_SCALE)
|
|
338
|
+
scaled_height = int(repost_image.height * self.REPOST_SCALE)
|
|
339
|
+
repost_image_scaled = repost_image.resize((scaled_width, scaled_height), Image.Resampling.LANCZOS)
|
|
301
340
|
|
|
302
341
|
return {
|
|
303
|
-
"height":
|
|
304
|
-
"
|
|
305
|
-
"width": repost_width,
|
|
306
|
-
"heights": repost_heights,
|
|
307
|
-
"repost": repost,
|
|
342
|
+
"height": scaled_height + self.REPOST_PADDING * 2, # 加上转发容器的内边距
|
|
343
|
+
"scaled_image": repost_image_scaled,
|
|
308
344
|
}
|
|
309
345
|
|
|
310
346
|
async def _calculate_image_grid_section(self, result: ParseResult, content_width: int) -> dict | None:
|
|
@@ -348,7 +384,10 @@ class CommonRenderer(BaseRenderer):
|
|
|
348
384
|
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
|
349
385
|
else:
|
|
350
386
|
# 多张图片,使用网格布局
|
|
351
|
-
|
|
387
|
+
# 计算图片尺寸,确保左右间距相同:间距 + (图片 + 间距) * 列数 = 总宽度
|
|
388
|
+
num_gaps = self.IMAGE_GRID_COLS + 1 # 3列有4个间距
|
|
389
|
+
max_size = (content_width - self.IMAGE_GRID_SPACING * num_gaps) // self.IMAGE_GRID_COLS
|
|
390
|
+
max_size = min(max_size, self.MAX_IMAGE_GRID_SIZE)
|
|
352
391
|
if img.width > max_size or img.height > max_size:
|
|
353
392
|
ratio = min(max_size / img.width, max_size / img.height)
|
|
354
393
|
new_size = (int(img.width * ratio), int(img.height * ratio))
|
|
@@ -373,7 +412,12 @@ class CommonRenderer(BaseRenderer):
|
|
|
373
412
|
|
|
374
413
|
# 计算高度
|
|
375
414
|
max_img_height = max(img.height for img in processed_images)
|
|
376
|
-
|
|
415
|
+
if len(processed_images) == 1:
|
|
416
|
+
# 单张图片
|
|
417
|
+
grid_height = max_img_height
|
|
418
|
+
else:
|
|
419
|
+
# 多张图片:上间距 + (图片 + 间距) * 行数
|
|
420
|
+
grid_height = self.IMAGE_GRID_SPACING + rows * (max_img_height + self.IMAGE_GRID_SPACING)
|
|
377
421
|
|
|
378
422
|
return {
|
|
379
423
|
"height": grid_height,
|
|
@@ -402,26 +446,24 @@ class CommonRenderer(BaseRenderer):
|
|
|
402
446
|
bottom = top + width
|
|
403
447
|
return img.crop((0, top, width, bottom))
|
|
404
448
|
|
|
405
|
-
def _draw_sections(
|
|
406
|
-
self, image: Image.Image, heights: list[tuple[str, int, Any]], card_width: int, fonts: dict
|
|
407
|
-
) -> None:
|
|
449
|
+
def _draw_sections(self, image: Image.Image, heights: list[tuple[str, int, Any]], card_width: int) -> None:
|
|
408
450
|
"""绘制所有内容到画布上"""
|
|
409
451
|
draw = ImageDraw.Draw(image)
|
|
410
452
|
y_pos = self.PADDING
|
|
411
453
|
|
|
412
454
|
for section_type, height, content in heights:
|
|
413
455
|
if section_type == "header":
|
|
414
|
-
y_pos = self._draw_header(image, draw, content, y_pos
|
|
456
|
+
y_pos = self._draw_header(image, draw, content, y_pos)
|
|
415
457
|
elif section_type == "title":
|
|
416
|
-
y_pos = self._draw_title(draw, content, y_pos, fonts["title"])
|
|
458
|
+
y_pos = self._draw_title(draw, content, y_pos, self.fonts["title"])
|
|
417
459
|
elif section_type == "cover":
|
|
418
460
|
y_pos = self._draw_cover(image, content, y_pos, card_width)
|
|
419
461
|
elif section_type == "text":
|
|
420
|
-
y_pos = self._draw_text(draw, content, y_pos, fonts["text"])
|
|
462
|
+
y_pos = self._draw_text(draw, content, y_pos, self.fonts["text"])
|
|
421
463
|
elif section_type == "extra":
|
|
422
|
-
y_pos = self._draw_extra(draw, content, y_pos, fonts["extra"])
|
|
464
|
+
y_pos = self._draw_extra(draw, content, y_pos, self.fonts["extra"])
|
|
423
465
|
elif section_type == "repost":
|
|
424
|
-
y_pos = self._draw_repost(image, draw, content, y_pos, card_width
|
|
466
|
+
y_pos = self._draw_repost(image, draw, content, y_pos, card_width)
|
|
425
467
|
elif section_type == "image_grid":
|
|
426
468
|
y_pos = self._draw_image_grid(image, content, y_pos, card_width)
|
|
427
469
|
|
|
@@ -472,92 +514,7 @@ class CommonRenderer(BaseRenderer):
|
|
|
472
514
|
placeholder.putalpha(mask)
|
|
473
515
|
return placeholder
|
|
474
516
|
|
|
475
|
-
def
|
|
476
|
-
"""加载并处理转发头像(小尺寸圆形)"""
|
|
477
|
-
if not avatar or not avatar.exists():
|
|
478
|
-
return None
|
|
479
|
-
|
|
480
|
-
try:
|
|
481
|
-
avatar_img = Image.open(avatar)
|
|
482
|
-
|
|
483
|
-
# 转换为 RGBA 模式
|
|
484
|
-
if avatar_img.mode != "RGBA":
|
|
485
|
-
avatar_img = avatar_img.convert("RGBA")
|
|
486
|
-
|
|
487
|
-
# 使用超采样技术提高质量
|
|
488
|
-
scale = self.AVATAR_UPSCALE_FACTOR
|
|
489
|
-
temp_size = self.REPOST_AVATAR_SIZE * scale
|
|
490
|
-
avatar_img = avatar_img.resize((temp_size, temp_size), Image.Resampling.LANCZOS)
|
|
491
|
-
|
|
492
|
-
# 创建高分辨率圆形遮罩
|
|
493
|
-
mask = Image.new("L", (temp_size, temp_size), 0)
|
|
494
|
-
mask_draw = ImageDraw.Draw(mask)
|
|
495
|
-
mask_draw.ellipse((0, 0, temp_size - 1, temp_size - 1), fill=255)
|
|
496
|
-
|
|
497
|
-
# 应用遮罩
|
|
498
|
-
output_avatar = Image.new("RGBA", (temp_size, temp_size), (0, 0, 0, 0))
|
|
499
|
-
output_avatar.paste(avatar_img, (0, 0))
|
|
500
|
-
output_avatar.putalpha(mask)
|
|
501
|
-
|
|
502
|
-
# 缩小到目标尺寸
|
|
503
|
-
output_avatar = output_avatar.resize(
|
|
504
|
-
(self.REPOST_AVATAR_SIZE, self.REPOST_AVATAR_SIZE), Image.Resampling.LANCZOS
|
|
505
|
-
)
|
|
506
|
-
|
|
507
|
-
return output_avatar
|
|
508
|
-
except Exception:
|
|
509
|
-
return None
|
|
510
|
-
|
|
511
|
-
def _create_repost_avatar_placeholder(self) -> Image.Image:
|
|
512
|
-
"""创建转发头像占位符"""
|
|
513
|
-
placeholder = Image.new("RGBA", (self.REPOST_AVATAR_SIZE, self.REPOST_AVATAR_SIZE), (0, 0, 0, 0))
|
|
514
|
-
draw = ImageDraw.Draw(placeholder)
|
|
515
|
-
|
|
516
|
-
# 绘制圆形背景
|
|
517
|
-
draw.ellipse(
|
|
518
|
-
(0, 0, self.REPOST_AVATAR_SIZE - 1, self.REPOST_AVATAR_SIZE - 1), fill=self.AVATAR_PLACEHOLDER_BG_COLOR
|
|
519
|
-
)
|
|
520
|
-
|
|
521
|
-
# 绘制简单的用户图标
|
|
522
|
-
center_x = self.REPOST_AVATAR_SIZE // 2
|
|
523
|
-
head_radius = int(self.REPOST_AVATAR_SIZE * self.AVATAR_HEAD_RADIUS_RATIO)
|
|
524
|
-
head_y = int(self.REPOST_AVATAR_SIZE * self.AVATAR_HEAD_RATIO)
|
|
525
|
-
draw.ellipse(
|
|
526
|
-
(
|
|
527
|
-
center_x - head_radius,
|
|
528
|
-
head_y - head_radius,
|
|
529
|
-
center_x + head_radius,
|
|
530
|
-
head_y + head_radius,
|
|
531
|
-
),
|
|
532
|
-
fill=self.AVATAR_PLACEHOLDER_FG_COLOR,
|
|
533
|
-
)
|
|
534
|
-
|
|
535
|
-
# 肩部
|
|
536
|
-
shoulder_y = int(self.REPOST_AVATAR_SIZE * self.AVATAR_SHOULDER_Y_RATIO)
|
|
537
|
-
shoulder_width = int(self.REPOST_AVATAR_SIZE * self.AVATAR_SHOULDER_WIDTH_RATIO)
|
|
538
|
-
shoulder_height = int(self.REPOST_AVATAR_SIZE * self.AVATAR_SHOULDER_HEIGHT_RATIO)
|
|
539
|
-
draw.ellipse(
|
|
540
|
-
(
|
|
541
|
-
center_x - shoulder_width // 2,
|
|
542
|
-
shoulder_y,
|
|
543
|
-
center_x + shoulder_width // 2,
|
|
544
|
-
shoulder_y + shoulder_height,
|
|
545
|
-
),
|
|
546
|
-
fill=self.AVATAR_PLACEHOLDER_FG_COLOR,
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
# 创建圆形遮罩
|
|
550
|
-
mask = Image.new("L", (self.REPOST_AVATAR_SIZE, self.REPOST_AVATAR_SIZE), 0)
|
|
551
|
-
mask_draw = ImageDraw.Draw(mask)
|
|
552
|
-
mask_draw.ellipse((0, 0, self.REPOST_AVATAR_SIZE - 1, self.REPOST_AVATAR_SIZE - 1), fill=255)
|
|
553
|
-
|
|
554
|
-
# 应用遮罩
|
|
555
|
-
placeholder.putalpha(mask)
|
|
556
|
-
return placeholder
|
|
557
|
-
|
|
558
|
-
def _draw_header(
|
|
559
|
-
self, image: Image.Image, draw: ImageDraw.ImageDraw, content: dict, y_pos: int, fonts: dict
|
|
560
|
-
) -> int:
|
|
517
|
+
def _draw_header(self, image: Image.Image, draw: ImageDraw.ImageDraw, content: dict, y_pos: int) -> int:
|
|
561
518
|
"""绘制 header 部分"""
|
|
562
519
|
x_pos = self.PADDING
|
|
563
520
|
|
|
@@ -575,14 +532,14 @@ class CommonRenderer(BaseRenderer):
|
|
|
575
532
|
|
|
576
533
|
# 发布者名称(蓝色)
|
|
577
534
|
for line in content["name_lines"]:
|
|
578
|
-
draw.text((text_x, text_y), line, fill=self.HEADER_COLOR, font=fonts["name"])
|
|
535
|
+
draw.text((text_x, text_y), line, fill=self.HEADER_COLOR, font=self.fonts["name"])
|
|
579
536
|
text_y += self.LINE_HEIGHTS["name"]
|
|
580
537
|
|
|
581
538
|
# 时间(灰色)
|
|
582
539
|
if content["time_lines"]:
|
|
583
540
|
text_y += self.NAME_TIME_GAP
|
|
584
541
|
for line in content["time_lines"]:
|
|
585
|
-
draw.text((text_x, text_y), line, fill=self.EXTRA_COLOR, font=fonts["extra"])
|
|
542
|
+
draw.text((text_x, text_y), line, fill=self.EXTRA_COLOR, font=self.fonts["extra"])
|
|
586
543
|
text_y += self.LINE_HEIGHTS["extra"]
|
|
587
544
|
|
|
588
545
|
return y_pos + content["height"] + self.SECTION_SPACING
|
|
@@ -596,7 +553,8 @@ class CommonRenderer(BaseRenderer):
|
|
|
596
553
|
|
|
597
554
|
def _draw_cover(self, image: Image.Image, cover_img: Image.Image, y_pos: int, card_width: int) -> int:
|
|
598
555
|
"""绘制封面"""
|
|
599
|
-
|
|
556
|
+
# 封面从左边padding开始,和文字、头像对齐
|
|
557
|
+
x_pos = self.PADDING
|
|
600
558
|
image.paste(cover_img, (x_pos, y_pos))
|
|
601
559
|
|
|
602
560
|
# 添加视频播放标志
|
|
@@ -651,13 +609,17 @@ class CommonRenderer(BaseRenderer):
|
|
|
651
609
|
return y_pos
|
|
652
610
|
|
|
653
611
|
def _draw_repost(
|
|
654
|
-
self, image: Image.Image, draw: ImageDraw.ImageDraw, content: dict, y_pos: int, card_width: int
|
|
612
|
+
self, image: Image.Image, draw: ImageDraw.ImageDraw, content: dict, y_pos: int, card_width: int
|
|
655
613
|
) -> int:
|
|
656
614
|
"""绘制转发内容"""
|
|
657
|
-
#
|
|
615
|
+
# 获取缩放后的转发图片
|
|
616
|
+
repost_image = content["scaled_image"]
|
|
617
|
+
|
|
618
|
+
# 转发框占满整个内容区域,左右和边缘对齐
|
|
619
|
+
content_width = card_width - 2 * self.PADDING
|
|
658
620
|
repost_x = self.PADDING
|
|
659
621
|
repost_y = y_pos
|
|
660
|
-
repost_width =
|
|
622
|
+
repost_width = content_width # 转发框宽度等于内容区域宽度
|
|
661
623
|
repost_height = content["height"]
|
|
662
624
|
|
|
663
625
|
# 绘制转发背景(圆角矩形)
|
|
@@ -677,15 +639,12 @@ class CommonRenderer(BaseRenderer):
|
|
|
677
639
|
width=1,
|
|
678
640
|
)
|
|
679
641
|
|
|
680
|
-
#
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
# 计算转发卡片在转发容器中的位置(居中)
|
|
684
|
-
card_x = repost_x + self.REPOST_PADDING
|
|
642
|
+
# 转发图片在转发容器中居中
|
|
643
|
+
card_x = repost_x + (repost_width - repost_image.width) // 2
|
|
685
644
|
card_y = repost_y + self.REPOST_PADDING
|
|
686
645
|
|
|
687
|
-
#
|
|
688
|
-
image.paste(
|
|
646
|
+
# 将缩放后的转发图片贴到主画布上
|
|
647
|
+
image.paste(repost_image, (card_x, card_y))
|
|
689
648
|
|
|
690
649
|
return y_pos + repost_height + self.SECTION_SPACING
|
|
691
650
|
|
|
@@ -709,8 +668,11 @@ class CommonRenderer(BaseRenderer):
|
|
|
709
668
|
# 单张图片,使用完整的可用宽度,与视频封面保持一致
|
|
710
669
|
max_img_size = available_width
|
|
711
670
|
else:
|
|
712
|
-
# 多张图片,统一使用3
|
|
713
|
-
|
|
671
|
+
# 多张图片,统一使用3列布局(九宫格)
|
|
672
|
+
# 计算图片尺寸,确保所有间距相同
|
|
673
|
+
num_gaps = cols + 1 # 3列有4个间距
|
|
674
|
+
max_img_size = (available_width - img_spacing * num_gaps) // cols
|
|
675
|
+
max_img_size = min(max_img_size, self.MAX_IMAGE_GRID_SIZE)
|
|
714
676
|
|
|
715
677
|
current_y = y_pos
|
|
716
678
|
|
|
@@ -722,10 +684,11 @@ class CommonRenderer(BaseRenderer):
|
|
|
722
684
|
# 计算这一行的最大高度
|
|
723
685
|
max_height = max(img.height for img in row_images)
|
|
724
686
|
|
|
725
|
-
#
|
|
687
|
+
# 绘制这一行的图片
|
|
726
688
|
for i, img in enumerate(row_images):
|
|
727
|
-
|
|
728
|
-
|
|
689
|
+
# 每张图片左侧都有间距:间距 + (间距 + 图片) * i
|
|
690
|
+
img_x = self.PADDING + img_spacing + i * (max_img_size + img_spacing)
|
|
691
|
+
img_y = current_y + img_spacing # 每行上方都有间距
|
|
729
692
|
|
|
730
693
|
# 居中放置图片
|
|
731
694
|
y_offset = (max_height - img.height) // 2
|
|
@@ -735,9 +698,9 @@ class CommonRenderer(BaseRenderer):
|
|
|
735
698
|
if has_more and row == rows - 1 and i == len(row_images) - 1 and len(images) == self.MAX_IMAGES_DISPLAY:
|
|
736
699
|
self._draw_more_indicator(image, img_x, img_y, max_img_size, max_height, remaining_count)
|
|
737
700
|
|
|
738
|
-
current_y +=
|
|
701
|
+
current_y += img_spacing + max_height
|
|
739
702
|
|
|
740
|
-
return current_y + self.SECTION_SPACING
|
|
703
|
+
return current_y + img_spacing + self.SECTION_SPACING
|
|
741
704
|
|
|
742
705
|
def _draw_more_indicator(
|
|
743
706
|
self, image: Image.Image, img_x: int, img_y: int, img_width: int, img_height: int, count: int
|
|
@@ -745,23 +708,19 @@ class CommonRenderer(BaseRenderer):
|
|
|
745
708
|
"""在图片上绘制+N指示器"""
|
|
746
709
|
draw = ImageDraw.Draw(image)
|
|
747
710
|
|
|
748
|
-
#
|
|
711
|
+
# 创建半透明黑色遮罩(透明度 1/4)
|
|
749
712
|
overlay = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 0))
|
|
750
713
|
overlay_draw = ImageDraw.Draw(overlay)
|
|
751
|
-
overlay_draw.rectangle((0, 0, img_width - 1, img_height - 1), fill=(0, 0, 0,
|
|
714
|
+
overlay_draw.rectangle((0, 0, img_width - 1, img_height - 1), fill=(0, 0, 0, 100))
|
|
752
715
|
|
|
753
716
|
# 将遮罩贴到图片上
|
|
754
717
|
image.paste(overlay, (img_x, img_y), overlay)
|
|
755
718
|
|
|
756
719
|
# 绘制+N文字
|
|
757
720
|
text = f"+{count}"
|
|
758
|
-
#
|
|
759
|
-
font_size = min(img_width, img_height) //
|
|
760
|
-
|
|
761
|
-
font_path = Path(__file__).parent / "fonts" / "HYSongYunLangHeiW-1.ttf"
|
|
762
|
-
font = ImageFont.truetype(font_path, font_size)
|
|
763
|
-
except Exception:
|
|
764
|
-
font = ImageFont.load_default()
|
|
721
|
+
# 使用更大的字体
|
|
722
|
+
font_size = min(img_width, img_height) // 4
|
|
723
|
+
font = ImageFont.truetype(self.font_path, font_size)
|
|
765
724
|
|
|
766
725
|
# 计算文字位置(居中)
|
|
767
726
|
bbox = font.getbbox(text)
|
|
@@ -773,107 +732,6 @@ class CommonRenderer(BaseRenderer):
|
|
|
773
732
|
# 绘制白色文字
|
|
774
733
|
draw.text((text_x, text_y), text, fill=(255, 255, 255, 255), font=font)
|
|
775
734
|
|
|
776
|
-
def _create_repost_card(self, content: dict, fonts: dict) -> Image.Image:
|
|
777
|
-
"""创建转发内容的完整卡片"""
|
|
778
|
-
scale = content["scale"]
|
|
779
|
-
card_width = content["width"]
|
|
780
|
-
heights = content["heights"]
|
|
781
|
-
|
|
782
|
-
# 计算卡片高度
|
|
783
|
-
card_height = sum(h for _, h, _ in heights) + self.PADDING * 2 + self.SECTION_SPACING * (len(heights) - 1)
|
|
784
|
-
|
|
785
|
-
# 创建转发卡片画布
|
|
786
|
-
repost_card = Image.new("RGB", (card_width, card_height), self.REPOST_BG_COLOR)
|
|
787
|
-
|
|
788
|
-
# 使用缩放后的字体
|
|
789
|
-
scaled_fonts = {}
|
|
790
|
-
for name, font in fonts.items():
|
|
791
|
-
if hasattr(font, "size"):
|
|
792
|
-
# 使用默认字体路径
|
|
793
|
-
font_path = Path(__file__).parent / "fonts" / "HYSongYunLangHeiW-1.ttf"
|
|
794
|
-
scaled_fonts[name] = ImageFont.truetype(font_path, int(font.size * scale))
|
|
795
|
-
else:
|
|
796
|
-
scaled_fonts[name] = font
|
|
797
|
-
|
|
798
|
-
# 绘制转发内容的所有部分
|
|
799
|
-
self._draw_sections(repost_card, heights, card_width, scaled_fonts)
|
|
800
|
-
|
|
801
|
-
return repost_card
|
|
802
|
-
|
|
803
|
-
def _draw_repost_header(
|
|
804
|
-
self, image: Image.Image, draw: ImageDraw.ImageDraw, content: dict, x_pos: int, y_pos: int, fonts: dict
|
|
805
|
-
) -> int:
|
|
806
|
-
"""绘制转发头部"""
|
|
807
|
-
# 绘制头像
|
|
808
|
-
avatar = content["avatar"] if content["avatar"] else self._create_repost_avatar_placeholder()
|
|
809
|
-
image.paste(avatar, (x_pos, y_pos), avatar)
|
|
810
|
-
|
|
811
|
-
# 绘制用户名和时间
|
|
812
|
-
text_x = x_pos + self.REPOST_AVATAR_SIZE + self.REPOST_AVATAR_GAP
|
|
813
|
-
text_y = y_pos
|
|
814
|
-
|
|
815
|
-
# 用户名
|
|
816
|
-
for line in content["name_lines"]:
|
|
817
|
-
draw.text((text_x, text_y), line, fill=self.TEXT_COLOR, font=fonts["repost_name"])
|
|
818
|
-
text_y += self.REPOST_LINE_HEIGHTS["repost_name"]
|
|
819
|
-
|
|
820
|
-
# 时间
|
|
821
|
-
if content["time_lines"]:
|
|
822
|
-
text_y += 4 # 用户名和时间间距
|
|
823
|
-
for line in content["time_lines"]:
|
|
824
|
-
draw.text((text_x, text_y), line, fill=self.EXTRA_COLOR, font=fonts["repost_time"])
|
|
825
|
-
text_y += self.REPOST_LINE_HEIGHTS["repost_time"]
|
|
826
|
-
|
|
827
|
-
return y_pos + max(self.REPOST_AVATAR_SIZE, text_y - y_pos)
|
|
828
|
-
|
|
829
|
-
def _draw_repost_text(
|
|
830
|
-
self, draw: ImageDraw.ImageDraw, lines: list[str], x_pos: int, y_pos: int, fonts: dict
|
|
831
|
-
) -> int:
|
|
832
|
-
"""绘制转发文本"""
|
|
833
|
-
current_y = y_pos
|
|
834
|
-
for line in lines:
|
|
835
|
-
draw.text((x_pos, current_y), line, fill=self.TEXT_COLOR, font=fonts["repost_text"])
|
|
836
|
-
current_y += self.REPOST_LINE_HEIGHTS["repost_text"]
|
|
837
|
-
return current_y
|
|
838
|
-
|
|
839
|
-
def _draw_repost_media(
|
|
840
|
-
self, image: Image.Image, media_items: list[Image.Image], x_pos: int, y_pos: int, content_width: int
|
|
841
|
-
) -> int:
|
|
842
|
-
"""绘制转发媒体(图片网格)"""
|
|
843
|
-
if not media_items:
|
|
844
|
-
return y_pos
|
|
845
|
-
|
|
846
|
-
# 计算网格布局
|
|
847
|
-
cols = min(3, len(media_items))
|
|
848
|
-
rows = (len(media_items) + cols - 1) // cols
|
|
849
|
-
|
|
850
|
-
# 计算每个图片的尺寸
|
|
851
|
-
max_img_size = min(300, content_width // 3)
|
|
852
|
-
img_spacing = 6
|
|
853
|
-
|
|
854
|
-
current_y = y_pos
|
|
855
|
-
|
|
856
|
-
for row in range(rows):
|
|
857
|
-
row_start = row * cols
|
|
858
|
-
row_end = min(row_start + cols, len(media_items))
|
|
859
|
-
row_items = media_items[row_start:row_end]
|
|
860
|
-
|
|
861
|
-
# 计算这一行的最大高度
|
|
862
|
-
max_height = max(img.height for img in row_items)
|
|
863
|
-
|
|
864
|
-
# 绘制这一行的图片
|
|
865
|
-
for i, img in enumerate(row_items):
|
|
866
|
-
img_x = x_pos + i * (max_img_size + img_spacing)
|
|
867
|
-
img_y = current_y
|
|
868
|
-
|
|
869
|
-
# 居中放置图片
|
|
870
|
-
y_offset = (max_height - img.height) // 2
|
|
871
|
-
image.paste(img, (img_x, img_y + y_offset))
|
|
872
|
-
|
|
873
|
-
current_y += max_height + img_spacing
|
|
874
|
-
|
|
875
|
-
return current_y
|
|
876
|
-
|
|
877
735
|
def _draw_rounded_rectangle(
|
|
878
736
|
self, image: Image.Image, bbox: tuple[int, int, int, int], fill_color: tuple[int, int, int], radius: int = 8
|
|
879
737
|
):
|
|
@@ -960,14 +818,3 @@ class CommonRenderer(BaseRenderer):
|
|
|
960
818
|
lines.append(current_line)
|
|
961
819
|
|
|
962
820
|
return lines if lines else [""]
|
|
963
|
-
|
|
964
|
-
@classmethod
|
|
965
|
-
def load_custom_fonts(cls):
|
|
966
|
-
"""加载字体"""
|
|
967
|
-
font_path = Path(__file__).parent / "fonts" / "HYSongYunLangHeiW-1.ttf"
|
|
968
|
-
# 加载主字体
|
|
969
|
-
main_fonts = {name: ImageFont.truetype(font_path, size) for name, size in cls.FONT_SIZES.items()}
|
|
970
|
-
# 加载转发字体
|
|
971
|
-
repost_fonts = {name: ImageFont.truetype(font_path, size) for name, size in cls.REPOST_FONT_SIZES.items()}
|
|
972
|
-
# 合并字体字典
|
|
973
|
-
cls.FONTS = {**main_fonts, **repost_fonts}
|
{nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/__init__.py
RENAMED
|
File without changes
|
{nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/exception.py
RENAMED
|
File without changes
|
{nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/helper.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/utils.py
RENAMED
|
File without changes
|