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.
Files changed (40) hide show
  1. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/PKG-INFO +2 -1
  2. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/README.md +1 -0
  3. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/pyproject.toml +2 -2
  4. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/config.py +7 -0
  5. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/base.py +2 -3
  6. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/bilibili/__init__.py +20 -13
  7. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/data.py +60 -27
  8. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/nga.py +7 -8
  9. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/youtube.py +2 -2
  10. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/__init__.py +2 -3
  11. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/base.py +5 -0
  12. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/common.py +157 -310
  13. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/__init__.py +0 -0
  14. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/constants.py +0 -0
  15. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/download/__init__.py +0 -0
  16. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/download/task.py +0 -0
  17. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/download/ytdlp.py +0 -0
  18. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/exception.py +0 -0
  19. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/helper.py +0 -0
  20. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/matchers/__init__.py +0 -0
  21. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/matchers/filter.py +0 -0
  22. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/matchers/preprocess.py +0 -0
  23. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/__init__.py +0 -0
  24. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/acfun.py +0 -0
  25. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/bilibili/opus.py +0 -0
  26. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/bilibili/video.py +0 -0
  27. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/cookie.py +0 -0
  28. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/douyin/__init__.py +0 -0
  29. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/douyin/slides.py +0 -0
  30. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/douyin/video.py +0 -0
  31. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/kuaishou.py +0 -0
  32. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/tiktok.py +0 -0
  33. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/twitter.py +0 -0
  34. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/weibo.py +0 -0
  35. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/parsers/xiaohongshu.py +0 -0
  36. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/default.py +0 -0
  37. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/fonts/HYSongYunLangHeiW-1.ttf +0 -0
  38. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/templates/weibo.html.jinja +0 -0
  39. {nonebot_plugin_parser-2.0.3 → nonebot_plugin_parser-2.0.5}/src/nonebot_plugin_parser/renders/weibo.py +0 -0
  40. {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
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"
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.3"
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
@@ -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: Sequence[str]):
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: Sequence[str]):
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
- avid, bvid = None, None
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
- bvid = _matched.group(1)
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
- avid = int(_matched.group(1))
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=f"简介:{video_info.desc}",
149
- author=self.create_author(video_info.owner.name, video_info.owner.face),
150
- contents=[self.create_video_content(video_task, cover_url, duration)],
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
- @dataclass
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
- @dataclass
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
- @dataclass
119
+
120
+ @dataclass(repr=False)
102
121
  class ParseResult:
103
122
  """完整的解析结果"""
104
123
 
105
124
  platform: Platform
106
125
  """平台信息"""
107
- title: str = ""
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
- author: Author | None = None
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
- def formart_datetime(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
173
- return datetime.fromtimestamp(self.timestamp).strftime(fmt) if self.timestamp else ""
174
-
175
- def __str__(self) -> str:
176
- return f"title: {self.title}\nplatform: {self.platform}\nauthor: {self.author}\ncontents: {self.contents}"
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
@@ -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]) -> ParseResult:
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: 解析结果(已下载资源,包含 Path)
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=Author(name=author) if author else None,
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.append(self.create_image_contents(video_info.thumbnail))
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.append(self.create_image_contents(video_info.thumbnail))
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)
@@ -38,6 +38,5 @@ from nonebot import get_driver
38
38
 
39
39
 
40
40
  @get_driver().on_startup
41
- async def _():
42
- # CommonRenderer.load_fonts()
43
- CommonRenderer.load_custom_fonts()
41
+ async def load_font():
42
+ _COMMON_RENDERER.load_font(pconfig.custom_font)
@@ -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 = 20
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
- REPOST_FONT_SIZES: ClassVar[dict[str, int]] = {"repost_name": 14, "repost_text": 14, "repost_time": 12}
77
- REPOST_LINE_HEIGHTS: ClassVar[dict[str, int]] = {"repost_name": 16, "repost_text": 20, "repost_time": 14}
78
- # 预加载的字体(在类定义后立即加载)
79
- FONTS: ClassVar[dict[str, ImageFont.FreeTypeFont | ImageFont.ImageFont]]
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 pconfig.append_url:
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
- # if not result.title and not result.text:
106
- # return None
111
+ # 调用内部方法生成图片
112
+ image = await self._create_card_image(result)
113
+ if not image:
114
+ return None
107
115
 
108
- # 使用预加载的字体
109
- fonts = self.FONTS
116
+ # 将图片转换为字节
117
+ output = BytesIO()
118
+ image.save(output, format="PNG")
119
+ return output.getvalue()
110
120
 
111
- # 加载并处理封面
112
- cover_img = self._load_and_resize_cover(await result.cover_path)
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
- if cover_img:
116
- card_width = max(cover_img.width + 2 * self.PADDING, self.MIN_CARD_WIDTH)
117
- else:
118
- card_width = max(self.DEFAULT_CARD_WIDTH, self.MIN_CARD_WIDTH)
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, fonts)
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
- output = BytesIO()
133
- image.save(output, format="PNG")
134
- return output.getvalue()
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
- def _load_and_resize_cover(self, cover_path: Path | None) -> Image.Image | None:
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
- if cover_img.width > self.MAX_COVER_WIDTH or cover_img.height > self.MAX_COVER_HEIGHT:
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
- new_width = int(cover_img.width * scale_ratio)
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
- if cover_img.width < self.MIN_COVER_WIDTH or cover_img.height < self.MIN_COVER_HEIGHT:
160
- width_ratio = self.MIN_COVER_WIDTH / cover_img.width
161
- height_ratio = self.MIN_COVER_HEIGHT / cover_img.height
162
- scale_ratio = max(width_ratio, height_ratio) # 使用max确保达到最小尺寸
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, fonts: dict
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, fonts)
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, fonts)
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, fonts: dict) -> dict | None:
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.formart_datetime() if result.timestamp else ""
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, fonts: dict) -> dict | None:
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
- repost_scale = self.REPOST_SCALE # 转发内容缩放比例
293
- repost_width = int(content_width * repost_scale)
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
- repost_cover = self._load_and_resize_cover(await repost.cover_path)
297
- repost_heights = await self._calculate_sections(repost, repost_cover, repost_width, fonts)
298
- repost_height = (
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": repost_height + self.REPOST_PADDING * 2, # 加上转发容器的内边距
304
- "scale": repost_scale,
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
- max_size = min(self.MAX_IMAGE_GRID_SIZE, content_width // self.IMAGE_GRID_COLS)
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
- grid_height = rows * max_img_height + (rows - 1) * self.IMAGE_GRID_SPACING # 图片间距
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, fonts)
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, fonts)
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 _load_and_process_repost_avatar(self, avatar: Path | None) -> Image.Image | None:
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
- x_pos = (card_width - cover_img.width) // 2
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, fonts: dict
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 = card_width - 2 * self.PADDING
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
- repost_card = self._create_repost_card(content, fonts)
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(repost_card, (card_x, card_y))
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
- max_img_size = min(self.MAX_IMAGE_GRID_SIZE, (available_width - 2 * img_spacing) // self.IMAGE_GRID_COLS)
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
- img_x = self.PADDING + i * (max_img_size + img_spacing)
728
- img_y = current_y
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 += max_height + img_spacing
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, 150))
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) // 6
760
- try:
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}