nonebot-plugin-apod 1.3.0__tar.gz → 1.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-apod
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary: 定时发送 NASA 每日提供的天文图片
5
5
  Keywords: nonebot2,NASA
6
6
  Author: lyqgzbl
@@ -8,12 +8,12 @@ Author-email: lyqgzbl <admin@lyqgzbl.com>
8
8
  License-Expression: MIT
9
9
  Requires-Dist: aiofiles>=24.1.0
10
10
  Requires-Dist: httpx>=0.28.0,<1.0.0
11
- Requires-Dist: pillow>=10.0.0
12
11
  Requires-Dist: nonebot2>=2.2.1,<3.0.0
13
12
  Requires-Dist: nonebot-plugin-argot>=0.2.2
14
13
  Requires-Dist: nonebot-plugin-localstore>=0.7.0,<1.0.0
15
14
  Requires-Dist: nonebot-plugin-apscheduler>=0.5.0,<1.0.0
16
15
  Requires-Dist: nonebot-plugin-alconna>=0.57.1,<1.0.0
16
+ Requires-Dist: nonebot-plugin-htmlrender>=0.3.0,<1.0.0
17
17
  Requires-Python: >=3.10
18
18
  Project-URL: Documentation, https://github.com/lyqgzbl/nonebot-plugin-apod#readme
19
19
  Project-URL: Homepage, https://github.com/lyqgzbl/nonebot-plugin-apod
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-apod"
3
- version = "1.3.0"
3
+ version = "1.3.2"
4
4
  description = "定时发送 NASA 每日提供的天文图片"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "lyqgzbl", email = "admin@lyqgzbl.com" }]
@@ -10,12 +10,12 @@ requires-python = ">=3.10"
10
10
  dependencies = [
11
11
  "aiofiles>=24.1.0",
12
12
  "httpx >=0.28.0, <1.0.0",
13
- "Pillow >=10.0.0",
14
13
  "nonebot2 >=2.2.1, <3.0.0",
15
14
  "nonebot-plugin-argot >=0.2.2",
16
15
  "nonebot-plugin-localstore >=0.7.0, <1.0.0",
17
16
  "nonebot-plugin-apscheduler >=0.5.0, <1.0.0",
18
17
  "nonebot-plugin-alconna >=0.57.1, <1.0.0",
18
+ "nonebot-plugin-htmlrender >=0.3.0, <1.0.0",
19
19
  ]
20
20
 
21
21
  [project.urls]
@@ -10,6 +10,7 @@ from nonebot.plugin import PluginMetadata, inherit_supported_adapters
10
10
  require("nonebot_plugin_argot")
11
11
  require("nonebot_plugin_alconna")
12
12
  require("nonebot_plugin_localstore")
13
+ require("nonebot_plugin_htmlrender")
13
14
  require("nonebot_plugin_apscheduler")
14
15
  import nonebot_plugin_localstore as store
15
16
  from nonebot_plugin_argot import Image, Text
@@ -18,7 +19,7 @@ from nonebot_plugin_argot.extension import ArgotExtension
18
19
  from nonebot_plugin_alconna.uniseg import UniMessage, MsgTarget
19
20
  from nonebot_plugin_alconna import Args, Match, Option, Alconna, CommandMeta, on_alconna
20
21
 
21
- from .config import Config, plugin_config, get_cache_image, set_cache_image
22
+ from .config import Config, plugin_config, get_cache_image
22
23
  from .apod import (
23
24
  generate_job_id,
24
25
  remove_apod_task,
@@ -46,7 +47,7 @@ __plugin_meta__ = PluginMetadata(
46
47
  supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
47
48
  extra={
48
49
  "author": "lyqgzbl <admin@lyqgzbl.com>",
49
- "version": "1.3.0",
50
+ "version": "1.3.2",
50
51
  },
51
52
  )
52
53
 
@@ -169,10 +170,9 @@ async def apod_command_handle():
169
170
  },
170
171
  )
171
172
  )
172
- cache_image = await get_cache_image() or await generate_apod_image()
173
+ cache_image = await get_cache_image(generate_apod_image)
173
174
  if not cache_image:
174
175
  await apod_command.finish("发送今日的天文一图失败")
175
- await set_cache_image(cache_image)
176
176
  url = data.get("hdurl", data["url"]) if plugin_config.apod_hd_image else data["url"]
177
177
  await UniMessage.image(raw=cache_image).send(
178
178
  reply_to=True,
@@ -13,7 +13,7 @@ from nonebot_plugin_alconna.uniseg import MsgTarget, Target, UniMessage
13
13
 
14
14
  from .infopuzzle import generate_apod_image
15
15
  from .utils import translate_text_auto, ensure_apod_data
16
- from .config import plugin_config, get_cache_image, set_cache_image, clear_cache_image
16
+ from .config import plugin_config, get_cache_image, clear_cache_image
17
17
 
18
18
 
19
19
  driver = get_driver()
@@ -139,14 +139,13 @@ async def send_apod(target: MsgTarget):
139
139
  expired_at=timedelta(minutes=2),
140
140
  )
141
141
  return
142
- cache_image = await get_cache_image() or await generate_apod_image()
142
+ cache_image = await get_cache_image(generate_apod_image)
143
143
  if not cache_image:
144
144
  await UniMessage.text("发送今日的天文一图失败,请稍后再试。").send(
145
145
  target=target,
146
146
  bot=bot,
147
147
  )
148
148
  return
149
- await set_cache_image(cache_image)
150
149
  url = data.get("hdurl", data["url"]) if plugin_config.apod_hd_image else data["url"]
151
150
  message = await UniMessage.image(raw=cache_image).send(
152
151
  target=target,
@@ -1,8 +1,10 @@
1
- from asyncio import Lock
1
+ from collections.abc import Awaitable, Callable
2
2
  from pydantic import BaseModel
3
3
 
4
4
  from nonebot import get_plugin_config
5
5
 
6
+ from .singleflight import SingleFlight
7
+
6
8
 
7
9
  class Config(BaseModel):
8
10
  apod_api_key: str | None = None
@@ -32,25 +34,41 @@ plugin_config = get_plugin_config(Config)
32
34
 
33
35
 
34
36
  # 缓存天文一图图片
35
- cache_image = None
36
- cache_lock = Lock()
37
+ cache_image: bytes | None = None
38
+ cache_flight: SingleFlight[bytes | None] = SingleFlight()
39
+ cache_lock = cache_flight
37
40
 
38
41
 
39
42
  # 获取缓存图片
40
- async def get_cache_image():
41
- async with cache_lock:
43
+ async def get_cache_image(
44
+ generator: Callable[[], Awaitable[bytes | None]] | None = None,
45
+ ) -> bytes | None:
46
+ global cache_image
47
+ if cache_image is not None:
42
48
  return cache_image
43
49
 
50
+ if generator is None:
51
+ return None
52
+
53
+ async def _generate_and_cache() -> bytes | None:
54
+ global cache_image
55
+ if cache_image is not None:
56
+ return cache_image
57
+ img = await generator()
58
+ if img:
59
+ cache_image = img
60
+ return img
61
+
62
+ return await cache_flight.do("apod_image", _generate_and_cache)
63
+
44
64
 
45
65
  # 设置缓存图片
46
- async def set_cache_image(image):
66
+ async def set_cache_image(image: bytes | None):
47
67
  global cache_image
48
- async with cache_lock:
49
- cache_image = image
68
+ cache_image = image
50
69
 
51
70
 
52
71
  # 清除缓存图片
53
72
  async def clear_cache_image():
54
73
  global cache_image
55
- async with cache_lock:
56
- cache_image = None
74
+ cache_image = None
@@ -0,0 +1,49 @@
1
+ body {
2
+ font-family: -apple-system, BlinkMacSystemFont, "Microsoft Yahei", "Noto Sans", sans-serif, monospace;
3
+ line-height: 1.6;
4
+ margin: 0;
5
+ padding: 20px;
6
+ background-color: #1e1e1e;
7
+ color: #e0e0e0;
8
+ }
9
+
10
+ .container {
11
+ max-width: 800px;
12
+ margin: 0 auto;
13
+ background: #2c2c2c;
14
+ padding: 20px;
15
+ border-radius: 8px;
16
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
17
+ }
18
+
19
+ h1, h2 {
20
+ text-align: center;
21
+ }
22
+
23
+ .image-container {
24
+ text-align: center;
25
+ margin: 20px 0;
26
+ }
27
+
28
+ .image-container img {
29
+ max-width: 100%;
30
+ height: auto;
31
+ border-radius: 8px;
32
+ border: 1px solid #4a4a4a;
33
+ }
34
+
35
+ .explanation {
36
+ text-align: left;
37
+ font-size: 1.1em;
38
+ margin: 0;
39
+ }
40
+
41
+ .info {
42
+ text-align: left;
43
+ font-size: 0.9em;
44
+ color: #a0a0a0;
45
+ }
46
+
47
+ .info p {
48
+ margin: 5px 0;
49
+ }
@@ -0,0 +1,48 @@
1
+ body {
2
+ font-family: -apple-system, BlinkMacSystemFont, "Microsoft Yahei", "Noto Sans", sans-serif, monospace;
3
+ line-height: 1.6;
4
+ margin: 0;
5
+ padding: 20px;
6
+ background-color: #f4f4f4;
7
+ color: #333;
8
+ }
9
+
10
+ .container {
11
+ max-width: 800px;
12
+ margin: 0 auto;
13
+ background: #fff;
14
+ padding: 20px;
15
+ border-radius: 8px;
16
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
17
+ }
18
+
19
+ h1, h2 {
20
+ text-align: center;
21
+ }
22
+
23
+ .image-container {
24
+ text-align: center;
25
+ margin: 20px 0;
26
+ }
27
+
28
+ .image-container img {
29
+ max-width: 100%;
30
+ height: auto;
31
+ border-radius: 8px;
32
+ }
33
+
34
+ .explanation {
35
+ text-align: left;
36
+ font-size: 1.1em;
37
+ margin: 0;
38
+ }
39
+
40
+ .info {
41
+ text-align: left;
42
+ font-size: 0.9em;
43
+ color: #555;
44
+ }
45
+
46
+ .info p {
47
+ margin: 5px 0;
48
+ }
@@ -0,0 +1,59 @@
1
+ import json
2
+ from html import escape
3
+ from pathlib import Path
4
+
5
+ import aiofiles
6
+ from nonebot.log import logger
7
+ import nonebot_plugin_localstore as store
8
+ from nonebot_plugin_htmlrender import md_to_pic
9
+
10
+ from .config import plugin_config
11
+ from .utils import ensure_apod_data, translate_text_auto
12
+
13
+ infopuzzle_mode = plugin_config.apod_infopuzzle_dark_mode
14
+ apod_cache_json = store.get_plugin_cache_file("apod.json")
15
+
16
+
17
+ async def apod_json_to_md(apod_json: dict) -> str:
18
+ title = escape(apod_json["title"])
19
+ explanation = apod_json["explanation"]
20
+ url = apod_json["url"]
21
+ copyright = escape(apod_json.get("copyright", "无"))
22
+ date = escape(apod_json["date"])
23
+ explanation = escape(await translate_text_auto(explanation))
24
+ return f"""<div class="container">
25
+ <h1>今日天文一图</h1>
26
+ <h2>{title}</h2>
27
+
28
+ <div class="image-container">
29
+ <img src="{url}" alt="APOD">
30
+ </div>
31
+
32
+ <p class="explanation">{explanation}</p>
33
+
34
+ <div class="info">
35
+ <p><strong>版权:</strong> {copyright}</p>
36
+ <p><strong>日期:</strong> {date}</p>
37
+ </div>
38
+ </div>
39
+ """
40
+
41
+
42
+ async def generate_apod_image() -> bytes | None:
43
+ try:
44
+ if not await ensure_apod_data():
45
+ return None
46
+ async with aiofiles.open(apod_cache_json, encoding="utf-8") as f:
47
+ content = await f.read()
48
+ data = json.loads(content)
49
+ md_content = await apod_json_to_md(data)
50
+ css_file = (
51
+ Path(__file__).parent
52
+ / "css"
53
+ / ("dark.css" if infopuzzle_mode else "light.css")
54
+ )
55
+ img_bytes = await md_to_pic(md_content, width=600, css_path=str(css_file))
56
+ return img_bytes
57
+ except Exception as e:
58
+ logger.error(f"生成 NASA APOD 图片时发生错误:{e}")
59
+ return None
@@ -0,0 +1,38 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from typing import Any, Generic, TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class SingleFlight(Generic[T]):
9
+ def __init__(self) -> None:
10
+ self._calls: dict[Any, asyncio.Future] = {}
11
+ self._lock = asyncio.Lock()
12
+
13
+ async def do(self, key: Any, fn: Callable[[], Awaitable[T]]) -> T:
14
+ async with self._lock:
15
+ if key in self._calls:
16
+ fut = self._calls[key]
17
+ is_leader = False
18
+ else:
19
+ loop = asyncio.get_running_loop()
20
+ fut = loop.create_future()
21
+ self._calls[key] = fut
22
+ is_leader = True
23
+
24
+ if not is_leader:
25
+ return await asyncio.shield(fut)
26
+
27
+ try:
28
+ res = await fn()
29
+ if not fut.done():
30
+ fut.set_result(res)
31
+ return res
32
+ except BaseException as e:
33
+ if not fut.done():
34
+ fut.set_exception(e)
35
+ raise
36
+ finally:
37
+ async with self._lock:
38
+ self._calls.pop(key, None)
@@ -5,6 +5,7 @@ import hashlib
5
5
  import asyncio
6
6
  import contextlib
7
7
  from datetime import datetime
8
+ from typing import Any
8
9
 
9
10
  import httpx
10
11
  import aiofiles
@@ -31,9 +32,7 @@ openai_model_name = (
31
32
  plugin_config.apod_openai_model_name
32
33
  if plugin_config.apod_openai_trans or plugin_config.apod_openai_model_name
33
34
  else (
34
- plugin_config.apod_qwen_mt_model_name
35
- if plugin_config.apod_qwen_trans
36
- else None
35
+ plugin_config.apod_qwen_mt_model_name if plugin_config.apod_qwen_trans else None
37
36
  )
38
37
  )
39
38
  OPENAI_API_URL = (
@@ -147,7 +146,7 @@ async def openai_translate_text(
147
146
  "Authorization": f"Bearer {api_key}",
148
147
  "Content-Type": "application/json",
149
148
  }
150
- payload = {
149
+ payload: dict[str, Any] = {
151
150
  "messages": [
152
151
  {
153
152
  "role": "system",
@@ -1,287 +0,0 @@
1
- import json
2
- from io import BytesIO
3
-
4
- import aiofiles
5
- from PIL import Image, ImageDraw, ImageFont
6
- from nonebot.log import logger
7
- from nonebot import get_driver
8
- import nonebot_plugin_localstore as store
9
-
10
- from .config import plugin_config
11
- from .utils import ensure_apod_data, translate_text_auto, get_httpx_client
12
-
13
-
14
- FontLike = ImageFont.FreeTypeFont | ImageFont.ImageFont
15
- SCALE = 2
16
- CANVAS_WIDTH = 600 * SCALE
17
- PADDING = 35 * SCALE
18
- CARD_PADDING = 20 * SCALE
19
- CONTENT_WIDTH = CANVAS_WIDTH - 2 * PADDING - 2 * CARD_PADDING
20
- SPACING = 20 * SCALE
21
- CORNER_RADIUS = 8 * SCALE
22
-
23
- FONT_BASE_URL = (
24
- "https://raw.githubusercontent.com/"
25
- "TCOTC/siyuan-ttf-HarmonyOS_Sans_SC-and-Twemoji/"
26
- "main/HarmonyOS_Sans_Backup/HarmonyOS_SansSC/"
27
- )
28
- FONTS = {
29
- "regular": "HarmonyOS_SansSC_Regular.ttf",
30
- "bold": "HarmonyOS_SansSC_Bold.ttf",
31
- }
32
-
33
- data_dir = store.get_plugin_data_dir()
34
- apod_cache_json = store.get_plugin_cache_file("apod.json")
35
- dark_mode = plugin_config.apod_infopuzzle_dark_mode
36
-
37
- THEMES = {
38
- False: {
39
- "bg": (244, 244, 244),
40
- "card_bg": (255, 255, 255),
41
- "title_color": (51, 51, 51),
42
- "text_color": (51, 51, 51),
43
- "info_color": (85, 85, 85),
44
- },
45
- True: {
46
- "bg": (30, 30, 30),
47
- "card_bg": (44, 44, 44),
48
- "title_color": (224, 224, 224),
49
- "text_color": (224, 224, 224),
50
- "info_color": (160, 160, 160),
51
- },
52
- }
53
-
54
- driver = get_driver()
55
-
56
-
57
- @driver.on_startup
58
- async def _download_fonts():
59
- client = get_httpx_client()
60
- for name, filename in FONTS.items():
61
- path = data_dir / filename
62
- if path.exists():
63
- continue
64
- url = FONT_BASE_URL + filename
65
- logger.info(f"正在下载 HarmonyOS Sans SC {name}...")
66
- try:
67
- resp = await client.get(url, timeout=60)
68
- resp.raise_for_status()
69
- async with aiofiles.open(path, "wb") as f:
70
- await f.write(resp.content)
71
- logger.info(f"HarmonyOS Sans SC {name} 下载完成")
72
- except Exception as e:
73
- logger.warning(f"下载 HarmonyOS Sans SC {name} 失败: {e}")
74
-
75
-
76
- def _load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | None:
77
- key = "bold" if bold else "regular"
78
- path = data_dir / FONTS[key]
79
- if path.exists():
80
- return ImageFont.truetype(str(path), size)
81
- if bold:
82
- regular = data_dir / FONTS["regular"]
83
- if regular.exists():
84
- return ImageFont.truetype(str(regular), size)
85
- return None
86
-
87
-
88
- def _wrap_text(
89
- draw: ImageDraw.ImageDraw,
90
- text: str,
91
- font: FontLike,
92
- max_width: int,
93
- ) -> list[str]:
94
- lines: list[str] = []
95
- for paragraph in text.split("\n"):
96
- if not paragraph:
97
- lines.append("")
98
- continue
99
- line = ""
100
- for char in paragraph:
101
- test = line + char
102
- if draw.textlength(test, font=font) > max_width:
103
- if line:
104
- lines.append(line)
105
- line = char
106
- else:
107
- line = test
108
- if line:
109
- lines.append(line)
110
- return lines
111
-
112
-
113
- def _draw_centered_lines(
114
- draw: ImageDraw.ImageDraw,
115
- lines: list[str],
116
- font: FontLike,
117
- y: int,
118
- canvas_width: int,
119
- fill: tuple[int, int, int],
120
- line_spacing: int,
121
- ) -> int:
122
- for line in lines:
123
- tw = draw.textlength(line, font=font)
124
- draw.text(((canvas_width - tw) / 2, y), line, fill=fill, font=font)
125
- y += _line_height(draw, font) + line_spacing
126
- return y
127
-
128
-
129
- def _line_height(draw: ImageDraw.ImageDraw, font: FontLike) -> int:
130
- return int(draw.textbbox((0, 0), "测试Tg", font=font)[3])
131
-
132
-
133
- def _round_corners(img: Image.Image, radius: int) -> Image.Image:
134
- img = img.convert("RGBA")
135
- mask = Image.new("L", img.size, 0)
136
- draw = ImageDraw.Draw(mask)
137
- draw.rounded_rectangle((0, 0, *img.size), radius=radius, fill=255)
138
- img.putalpha(mask)
139
- return img
140
-
141
-
142
- async def _fetch_image(url: str) -> Image.Image | None:
143
- try:
144
- client = get_httpx_client()
145
- resp = await client.get(url, timeout=20)
146
- resp.raise_for_status()
147
- return Image.open(BytesIO(resp.content)).convert("RGB")
148
- except Exception as e:
149
- logger.warning(f"下载天文图片失败: {e}")
150
- return None
151
-
152
-
153
- async def generate_apod_image() -> bytes | None:
154
- try:
155
- if not await ensure_apod_data():
156
- return None
157
-
158
- font_title = _load_font(28 * SCALE, bold=True)
159
- if font_title is None:
160
- logger.warning("缺少字体文件, 已降级为单图模式")
161
- return None
162
- font_subtitle = _load_font(26 * SCALE, bold=True)
163
- font_body = _load_font(20 * SCALE)
164
- font_info = _load_font(14 * SCALE)
165
- if font_subtitle is None or font_body is None or font_info is None:
166
- logger.warning("缺少字体文件, 已降级为单图模式")
167
- return None
168
-
169
- async with aiofiles.open(apod_cache_json, encoding="utf-8") as f:
170
- data = json.loads(await f.read())
171
-
172
- theme = THEMES[dark_mode]
173
- title_text = "今日天文一图"
174
- subtitle_text = data["title"]
175
- explanation = await translate_text_auto(data["explanation"])
176
- copyright_text = f"版权:{data.get('copyright', '无')}"
177
- date_text = f"日期:{data['date']}"
178
- image_url = data.get("url")
179
-
180
- tmp = Image.new("RGB", (1, 1))
181
- draw = ImageDraw.Draw(tmp)
182
-
183
- title_lines = _wrap_text(draw, title_text, font_title, CONTENT_WIDTH)
184
- subtitle_lines = _wrap_text(draw, subtitle_text, font_subtitle, CONTENT_WIDTH)
185
- body_lines = _wrap_text(draw, explanation, font_body, CONTENT_WIDTH)
186
-
187
- title_lh = _line_height(draw, font_title)
188
- subtitle_lh = _line_height(draw, font_subtitle)
189
- body_lh = _line_height(draw, font_body)
190
- info_lh = _line_height(draw, font_info)
191
-
192
- apod_img = await _fetch_image(image_url) if image_url else None
193
- img_height = 0
194
- if apod_img:
195
- ratio = CONTENT_WIDTH / apod_img.width
196
- img_height = int(apod_img.height * ratio)
197
- apod_img = apod_img.resize(
198
- (CONTENT_WIDTH, img_height), Image.Resampling.LANCZOS
199
- )
200
- apod_img = _round_corners(apod_img, CORNER_RADIUS)
201
-
202
- card_content_h = (
203
- 25 * SCALE
204
- + len(title_lines) * (title_lh + 4 * SCALE)
205
- + 25 * SCALE
206
- + len(subtitle_lines) * (subtitle_lh + 4 * SCALE)
207
- + 20 * SCALE
208
- + (img_height + SPACING if apod_img else 0)
209
- + len(body_lines) * (body_lh + 6 * SCALE)
210
- + 15 * SCALE
211
- + info_lh + 5 * SCALE
212
- + info_lh
213
- )
214
- card_height = 2 * CARD_PADDING + card_content_h
215
- canvas_height = 2 * PADDING + card_height
216
-
217
- canvas = Image.new("RGBA", (CANVAS_WIDTH, canvas_height), theme["bg"])
218
- draw = ImageDraw.Draw(canvas)
219
-
220
- card_x = PADDING
221
- card_y = PADDING
222
- draw.rounded_rectangle(
223
- [card_x, card_y, CANVAS_WIDTH - PADDING, card_y + card_height],
224
- radius=CORNER_RADIUS,
225
- fill=theme["card_bg"],
226
- )
227
-
228
- y = card_y + CARD_PADDING
229
- y += 25 * SCALE
230
- y = _draw_centered_lines(
231
- draw,
232
- title_lines,
233
- font_title,
234
- y,
235
- CANVAS_WIDTH,
236
- theme["title_color"],
237
- 4 * SCALE,
238
- )
239
- y += 25 * SCALE
240
-
241
- y = _draw_centered_lines(
242
- draw,
243
- subtitle_lines,
244
- font_subtitle,
245
- y,
246
- CANVAS_WIDTH,
247
- theme["title_color"],
248
- 4 * SCALE,
249
- )
250
- y += 20 * SCALE
251
-
252
- content_x = card_x + CARD_PADDING
253
- if apod_img:
254
- canvas.paste(apod_img, (content_x, y), apod_img)
255
- y += img_height + SPACING
256
-
257
- for line in body_lines:
258
- draw.text(
259
- (content_x, y),
260
- line,
261
- fill=theme["text_color"],
262
- font=font_body,
263
- )
264
- y += body_lh + 6 * SCALE
265
- y += 15 * SCALE
266
-
267
- draw.text(
268
- (content_x, y),
269
- copyright_text,
270
- fill=theme["info_color"],
271
- font=font_info,
272
- )
273
- y += info_lh + 5 * SCALE
274
- draw.text(
275
- (content_x, y),
276
- date_text,
277
- fill=theme["info_color"],
278
- font=font_info,
279
- )
280
-
281
- output = canvas.convert("RGB")
282
- buf = BytesIO()
283
- output.save(buf, format="PNG")
284
- return buf.getvalue()
285
- except Exception as e:
286
- logger.error(f"生成 NASA APOD 图片时发生错误:{e}")
287
- return None