tiebameow 0.2.8__py3-none-any.whl

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.
tiebameow/py.typed ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ from .config import RenderConfig
2
+ from .playwright_core import PlaywrightCore
3
+ from .renderer import Renderer
4
+
5
+ __all__ = ["RenderConfig", "PlaywrightCore", "Renderer"]
@@ -0,0 +1,18 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class RenderConfig(BaseModel):
7
+ """
8
+ 渲染配置类
9
+
10
+ Attributes:
11
+ width (int): 渲染宽度,默认为500。
12
+ height (int): 渲染高度,无需手动调整高度,默认为100。
13
+ quality (Literal["low", "medium", "high"]): 渲染质量,输出清晰度,默认为"medium"。
14
+ """
15
+
16
+ width: int = 500
17
+ height: int = 100
18
+ quality: Literal["low", "medium", "high"] = "medium"
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from asyncio import Lock
4
+ from typing import TYPE_CHECKING, Any, Literal, cast
5
+
6
+ if TYPE_CHECKING:
7
+ from collections.abc import Awaitable, Callable
8
+
9
+ from playwright.async_api import Browser, BrowserContext, Playwright, Route
10
+
11
+ from .config import RenderConfig
12
+
13
+
14
+ QUALITY_MAP_SCALE = {
15
+ "low": 1,
16
+ "medium": 1.5,
17
+ "high": 2,
18
+ }
19
+ QUALITY_MAP_OUTPUT: dict[str, dict[str, Any]] = {
20
+ "low": {
21
+ "type": "jpeg",
22
+ "quality": 60,
23
+ },
24
+ "medium": {
25
+ "type": "jpeg",
26
+ "quality": 80,
27
+ },
28
+ "high": {"type": "png"},
29
+ }
30
+
31
+ VALID_BROWSER_ENGINES = Literal["chromium", "firefox", "webkit"]
32
+
33
+
34
+ class PlaywrightCore:
35
+ def __init__(self, browser_engine: VALID_BROWSER_ENGINES | None = None) -> None:
36
+ if not self.check_installed():
37
+ raise ImportError(
38
+ "playwright is not installed. Please install it with 'pip install tiebameow[renderer]'.\n"
39
+ "You may also need to run 'playwright install' to install the necessary browsers."
40
+ )
41
+
42
+ self.playwright: Playwright | None = None
43
+ self.browser_engine = browser_engine or "chromium"
44
+ self.browser: Browser | None = None
45
+ self.contexts: dict[str, BrowserContext] = {}
46
+ self._lock = Lock()
47
+
48
+ @staticmethod
49
+ def check_installed() -> bool:
50
+ """检查Playwright包是否已安装。"""
51
+ try:
52
+ import playwright # noqa: F401
53
+ except ImportError:
54
+ return False
55
+ return True
56
+
57
+ async def _launch(self) -> None:
58
+ """在获取锁的前提下,启动Playwright和浏览器实例。"""
59
+ if self.browser is not None:
60
+ return
61
+
62
+ if self.playwright is None:
63
+ from playwright.async_api import async_playwright
64
+
65
+ self.playwright = await async_playwright().start()
66
+
67
+ engine = getattr(self.playwright, self.browser_engine)
68
+ if not engine:
69
+ raise ValueError(f"Invalid browser engine: {self.browser_engine}")
70
+ try:
71
+ self.browser = await engine.launch()
72
+ except AttributeError as e:
73
+ raise ValueError(f"Invalid browser engine: {self.browser_engine}") from e
74
+
75
+ async def launch(self) -> None:
76
+ """启动Playwright和浏览器实例。"""
77
+ async with self._lock:
78
+ await self._launch()
79
+
80
+ async def close(self) -> None:
81
+ """关闭所有浏览器上下文和浏览器实例。"""
82
+ async with self._lock:
83
+ for context in self.contexts.values():
84
+ await context.close()
85
+ self.contexts.clear()
86
+
87
+ if self.browser is not None:
88
+ await self.browser.close()
89
+ self.browser = None
90
+ if self.playwright is not None:
91
+ await self.playwright.stop()
92
+ self.playwright = None
93
+
94
+ async def _get_context(self, quality: str) -> BrowserContext:
95
+ """获取指定渲染图片质量的浏览器上下文。"""
96
+ if quality in self.contexts:
97
+ return self.contexts[quality]
98
+
99
+ async with self._lock:
100
+ if quality in self.contexts:
101
+ return self.contexts[quality]
102
+
103
+ if self.browser is None:
104
+ await self._launch()
105
+
106
+ browser = cast("Browser", self.browser)
107
+ scale = QUALITY_MAP_SCALE.get(quality, 1)
108
+ context = await browser.new_context(device_scale_factor=scale)
109
+ self.contexts[quality] = context
110
+ return context
111
+
112
+ async def render(
113
+ self,
114
+ html: str,
115
+ config: RenderConfig,
116
+ element: str | None = None,
117
+ request_handler: Callable[[Route], Awaitable[None]] | None = None,
118
+ ) -> bytes:
119
+ """使用Playwright渲染HTML为图片。
120
+
121
+ Args:
122
+ html: 要渲染的HTML内容
123
+ config: 渲染配置
124
+ element: 可选的CSS选择器,指定要截图的元素;如果为None,则截图整个页面
125
+ request_handler: 可选的请求处理函数,用于拦截和处理页面请求
126
+
127
+ Returns:
128
+ 渲染后的图片字节内容
129
+ """
130
+ context = await self._get_context(config.quality)
131
+ page = await context.new_page()
132
+
133
+ try:
134
+ await page.set_viewport_size({"width": config.width, "height": config.height})
135
+
136
+ if request_handler:
137
+ await page.route("http://tiebameow.local/**", request_handler)
138
+
139
+ await page.set_content(html)
140
+ await page.wait_for_load_state("networkidle")
141
+
142
+ if element:
143
+ screenshot = await page.locator(element).screenshot(**QUALITY_MAP_OUTPUT[config.quality])
144
+ else:
145
+ screenshot = await page.screenshot(full_page=True, **QUALITY_MAP_OUTPUT[config.quality])
146
+ return screenshot
147
+ finally:
148
+ await page.close()
@@ -0,0 +1,508 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ import jinja2
8
+ import yarl
9
+ from aiotieba.api.get_posts._classdef import Comment_p, Thread_p
10
+ from aiotieba.typing import Comment, Post, Thread
11
+
12
+ from ..client import Client
13
+ from ..models.dto import CommentDTO, PostDTO, ThreadDTO, ThreadpDTO
14
+ from ..parser import convert_aiotieba_comment, convert_aiotieba_post, convert_aiotieba_thread, convert_aiotieba_threadp
15
+ from ..utils.logger import logger
16
+ from .config import RenderConfig
17
+ from .playwright_core import PlaywrightCore
18
+ from .style import FONT_URL, font_path, get_font_style
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+ from pathlib import Path
23
+
24
+ from playwright.async_api import Route
25
+
26
+ type RenderContentType = (
27
+ ThreadDTO | Thread | ThreadpDTO | Thread_p | PostDTO | Post | CommentDTO | Comment | Comment_p
28
+ )
29
+
30
+
31
+ def format_date(dt: datetime | int | float) -> str:
32
+ if isinstance(dt, (int, float)):
33
+ if dt > 1e11:
34
+ dt = dt / 1000
35
+ dt = datetime.fromtimestamp(dt)
36
+ return dt.strftime("%Y-%m-%d %H:%M")
37
+
38
+
39
+ class Renderer:
40
+ """
41
+ 渲染器,用于将贴子数据渲染为图像
42
+
43
+ Args:
44
+ client: 用于获取资源的客户端实例,若为 None 则创建新的 Client 实例
45
+ config: 渲染配置,若为 None 则使用默认配置
46
+ template_dir: 自定义模板目录,若为 None 则使用内置模板
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ client: Client | None = None,
52
+ config: RenderConfig | None = None,
53
+ template_dir: str | Path | None = None,
54
+ ) -> None:
55
+ self.core = PlaywrightCore()
56
+
57
+ if config is None:
58
+ config = RenderConfig()
59
+ self.config = config
60
+
61
+ self.client = client or Client()
62
+ self._own_client = client is None
63
+ self._client_entered = False
64
+
65
+ loader: jinja2.BaseLoader
66
+ if template_dir:
67
+ loader = jinja2.FileSystemLoader(str(template_dir))
68
+ else:
69
+ loader = jinja2.PackageLoader("tiebameow.renderer", "templates")
70
+
71
+ self.env = jinja2.Environment(loader=loader, enable_async=True)
72
+ self.env.filters["format_date"] = format_date
73
+
74
+ async def close(self) -> None:
75
+ await self.core.close()
76
+ if self._own_client and self._client_entered:
77
+ await self.client.__aexit__(None, None, None)
78
+ self._client_entered = False
79
+
80
+ async def _ensure_client(self) -> None:
81
+ if self._own_client and not self._client_entered:
82
+ await self.client.__aenter__()
83
+ self._client_entered = True
84
+
85
+ async def __aenter__(self) -> Renderer:
86
+ await self._ensure_client()
87
+ try:
88
+ await self.core.launch()
89
+ except Exception:
90
+ await self.close()
91
+ raise
92
+ return self
93
+
94
+ async def __aexit__(
95
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any | None
96
+ ) -> None:
97
+ await self.close()
98
+
99
+ @staticmethod
100
+ def _get_portrait_url(portrait: str, size: Literal["s", "m", "l"] = "s") -> str:
101
+ """获取用户头像的本地URL"""
102
+ return str(
103
+ yarl.URL.build(
104
+ scheme="http", host="tiebameow.local", path="/portrait", query={"id": portrait, "size": size}
105
+ )
106
+ )
107
+
108
+ @staticmethod
109
+ def _get_image_url(image_hash: str, size: Literal["s", "m", "l"] = "s") -> str:
110
+ """获取图片的本地URL"""
111
+ return str(
112
+ yarl.URL.build(
113
+ scheme="http", host="tiebameow.local", path="/image", query={"hash": image_hash, "size": size}
114
+ )
115
+ )
116
+
117
+ @staticmethod
118
+ def _get_forum_icon_url(fname: str) -> str:
119
+ """获取吧头像的本地URL"""
120
+ return str(yarl.URL.build(scheme="http", host="tiebameow.local", path="/forum", query={"fname": fname}))
121
+
122
+ async def _handle_route(self, route: Route) -> None:
123
+ """
124
+ 处理 Playwright 的路由请求
125
+
126
+ 拦截对 tiebameow.local 的请求,并根据请求路径提供相应的资源。
127
+
128
+ Args:
129
+ route: Playwright 的路由对象
130
+ """
131
+ url = yarl.URL(route.request.url)
132
+
133
+ if url.host != "tiebameow.local":
134
+ await route.continue_()
135
+ return
136
+
137
+ if str(url) == FONT_URL:
138
+ if font_path.exists():
139
+ await route.fulfill(path=font_path)
140
+ else:
141
+ await route.abort()
142
+ return
143
+
144
+ try:
145
+ if url.path == "/portrait":
146
+ portrait = url.query.get("id")
147
+ size = url.query.get("size", "s")
148
+ if not portrait:
149
+ await route.abort()
150
+ return
151
+
152
+ path = ""
153
+ if size == "s":
154
+ path = "n"
155
+ elif size == "l":
156
+ path = "h"
157
+
158
+ real_url = yarl.URL.build(
159
+ scheme="http", host="tb.himg.baidu.com", path=f"/sys/portrait{path}/item/{portrait}"
160
+ )
161
+ await self._proxy_request(route, str(real_url))
162
+
163
+ elif url.path == "/image":
164
+ image_hash = url.query.get("hash")
165
+ size = url.query.get("size", "s")
166
+
167
+ if not image_hash:
168
+ await route.abort()
169
+ return
170
+
171
+ if size == "s":
172
+ real_url = yarl.URL.build(
173
+ scheme="http",
174
+ host="imgsrc.baidu.com",
175
+ path=f"/forum/w=720;q=60;g=0/sign=__/{image_hash}.jpg",
176
+ )
177
+ elif size == "m":
178
+ real_url = yarl.URL.build(
179
+ scheme="http",
180
+ host="imgsrc.baidu.com",
181
+ path=f"/forum/w=960;q=60;g=0/sign=__/{image_hash}.jpg",
182
+ )
183
+ elif size == "l":
184
+ real_url = yarl.URL.build(
185
+ scheme="http", host="imgsrc.baidu.com", path=f"/forum/pic/item/{image_hash}.jpg"
186
+ )
187
+ else:
188
+ await route.abort()
189
+ return
190
+
191
+ await self._proxy_request(route, str(real_url))
192
+
193
+ elif url.path == "/forum":
194
+ fname = url.query.get("fname")
195
+ if not fname:
196
+ await route.abort()
197
+ return
198
+
199
+ try:
200
+ forum_info = await self.client.get_forum(fname)
201
+ if forum_info and forum_info.small_avatar:
202
+ await self._proxy_request(route, forum_info.small_avatar)
203
+ else:
204
+ await route.abort()
205
+ except Exception:
206
+ await route.abort()
207
+
208
+ else:
209
+ await route.abort()
210
+
211
+ except Exception as e:
212
+ logger.error(f"Error handling route {url}: {e}")
213
+ await route.abort()
214
+
215
+ async def _proxy_request(self, route: Route, url: str) -> None:
216
+ try:
217
+ response = await self.client.get_image_bytes(url)
218
+ await route.fulfill(body=response.data)
219
+ except Exception as e:
220
+ logger.error(f"Failed to proxy request for {url}: {e}")
221
+ await route.abort()
222
+
223
+ async def _build_content_context(
224
+ self,
225
+ content: ThreadDTO | ThreadpDTO | PostDTO | CommentDTO,
226
+ max_image_count: int = 9,
227
+ show_link: bool = True,
228
+ ) -> dict[str, Any]:
229
+ """
230
+ 构建渲染内容上下文字典
231
+
232
+ Args:
233
+ content: 要构建上下文的内容,可以是 ThreadDTO、PostDTO 或 CommentDTO
234
+ max_image_count: 最大包含的图片数量,默认为 9
235
+ show_link: 是否显示 tid 和 pid,默认为 True
236
+
237
+ Returns:
238
+ dict[str, Any]: 包含渲染内容信息的字典
239
+ """
240
+ context: dict[str, Any] = {
241
+ "text": content.text,
242
+ "create_time": content.create_time,
243
+ "nick_name": content.author.show_name or f"uid:{content.author.user_id}",
244
+ "level": content.author.level,
245
+ "portrait_url": "",
246
+ "image_url_list": [],
247
+ "remain_image_count": 0,
248
+ "sub_text_list": [],
249
+ "sub_html_list": [],
250
+ "tid": content.tid,
251
+ "pid": content.pid,
252
+ }
253
+
254
+ if isinstance(content, (ThreadDTO, ThreadpDTO, PostDTO)):
255
+ context["image_hash_list"] = [img.hash for img in content.images]
256
+ else:
257
+ context["image_hash_list"] = []
258
+
259
+ if isinstance(content, (ThreadDTO, ThreadpDTO)):
260
+ context["title"] = content.title
261
+ if show_link:
262
+ context["sub_text_list"].append(f"tid: {content.tid}")
263
+ elif isinstance(content, PostDTO):
264
+ context["floor"] = content.floor
265
+ if show_link:
266
+ context["sub_text_list"].append(f"pid: {content.pid}")
267
+ context["comments"] = [await self._build_content_context(c, max_image_count) for c in content.comments]
268
+ elif isinstance(content, CommentDTO):
269
+ context["pid"] = content.cid
270
+ context["floor"] = content.floor
271
+ if show_link:
272
+ context["sub_text_list"].append(f"pid: {content.cid}")
273
+
274
+ if content.author.portrait:
275
+ size: Literal["s", "m", "l"] = "s" if isinstance(content, CommentDTO) else "m"
276
+ context["portrait_url"] = self._get_portrait_url(content.author.portrait, size=size)
277
+
278
+ if context["image_hash_list"]:
279
+ limit = min(max_image_count, len(context["image_hash_list"]))
280
+ context["image_url_list"] = [self._get_image_url(h, size="s") for h in context["image_hash_list"][:limit]]
281
+ context["remain_image_count"] = max(0, len(context["image_hash_list"]) - limit)
282
+
283
+ return context
284
+
285
+ async def _render_html(self, template_name: str, data: dict[str, Any]) -> str:
286
+ """
287
+ 使用指定模板渲染 HTML
288
+
289
+ Args:
290
+ template_name: 模板名称
291
+ data: 渲染数据字典
292
+
293
+ Returns:
294
+ str: 渲染后的 HTML 字符串
295
+ """
296
+ template = self.env.get_template(template_name)
297
+ html = await template.render_async(**data)
298
+ return html
299
+
300
+ async def _render_image(
301
+ self,
302
+ template_name: str,
303
+ config: RenderConfig | None = None,
304
+ data: dict[str, Any] | None = None,
305
+ element: str | None = None,
306
+ ) -> bytes:
307
+ """
308
+ 使用指定模板渲染图像
309
+
310
+ Args:
311
+ template_name: 模板名称
312
+ config: 渲染配置,若为 None 则使用默认配置
313
+ data: 渲染数据字典
314
+ element: 要截图的元素选择器,若为 None 则截图全页
315
+
316
+ Returns:
317
+ bytes: 渲染后的图像字节数据
318
+ """
319
+ html = await self._render_html(template_name, data or {})
320
+ image_bytes = await self.core.render(
321
+ html, config or self.config, element=element, request_handler=self._handle_route
322
+ )
323
+ return image_bytes
324
+
325
+ async def render_content(
326
+ self,
327
+ content: RenderContentType,
328
+ *,
329
+ max_image_count: int = 9,
330
+ prefix_html: str | None = None,
331
+ suffix_html: str | None = None,
332
+ title: str = "",
333
+ **config: Any,
334
+ ) -> bytes:
335
+ """
336
+ 渲染内容(贴子或回复)为图像
337
+
338
+ Args:
339
+ content: 要渲染的内容,可以是 Thread/Post 相关对象
340
+ max_image_count: 最大包含的图片数量,默认为 9
341
+ prefix_html: 文本前缀,可选,支持 HTML
342
+ suffix_html: 文本后缀,可选,支持 HTML
343
+ title: 覆盖标题,可选
344
+ **config: 其他渲染配置参数
345
+
346
+ Returns:
347
+ 生成的图像的字节数据
348
+ """
349
+ await self._ensure_client()
350
+
351
+ render_config = self.config.model_copy(update=config)
352
+
353
+ if isinstance(content, Thread):
354
+ content = convert_aiotieba_thread(content)
355
+ elif isinstance(content, Thread_p):
356
+ content = convert_aiotieba_threadp(content)
357
+ elif isinstance(content, Post):
358
+ content = convert_aiotieba_post(content)
359
+ elif isinstance(content, Comment | Comment_p):
360
+ content = convert_aiotieba_comment(content)
361
+
362
+ content_context = await self._build_content_context(content, max_image_count)
363
+
364
+ if title and isinstance(content, ThreadDTO):
365
+ content_context["title"] = title
366
+
367
+ if prefix_html:
368
+ content_context["prefix_html"] = prefix_html
369
+ if suffix_html:
370
+ content_context["suffix_html"] = suffix_html
371
+
372
+ forum_icon_url = ""
373
+ if content.fname:
374
+ forum_icon_url = self._get_forum_icon_url(content.fname)
375
+
376
+ data = {
377
+ "content": content_context,
378
+ "forum": content.fname,
379
+ "forum_icon_url": forum_icon_url,
380
+ "prefix_html": prefix_html or "",
381
+ "suffix_html": suffix_html or "",
382
+ "style_list": [get_font_style()],
383
+ }
384
+
385
+ image_bytes = await self._render_image("thread.html", config=render_config, data=data)
386
+ return image_bytes
387
+
388
+ async def render_thread_detail(
389
+ self,
390
+ thread: ThreadDTO | ThreadpDTO | Thread | Thread_p,
391
+ posts: Sequence[PostDTO | Post] | None = None,
392
+ *,
393
+ max_image_count: int = 9,
394
+ prefix_html: str | None = None,
395
+ suffix_html: str | None = None,
396
+ ignore_first_floor: bool = True,
397
+ show_thread_info: bool = True,
398
+ show_link: bool = True,
399
+ **config: Any,
400
+ ) -> bytes:
401
+ """
402
+ 渲染贴子详情(包含回复)为图像
403
+
404
+ Args:
405
+ thread: 要渲染的贴子
406
+ posts: 要渲染的回复列表
407
+ max_image_count: 每个楼层最大包含的图片数量,默认为 9
408
+ prefix_html: 贴子文本前缀,可选,支持 HTML
409
+ suffix_html: 贴子文本后缀,可选,支持 HTML
410
+ ignore_first_floor: 是否忽略渲染第一楼(楼主),默认为 True
411
+ show_thread_info: 是否显示贴子信息(转发、点赞、回复数),默认为 True
412
+ show_link: 是否显示 tid 和 pid,默认为 True
413
+ **config: 其他渲染配置参数
414
+
415
+ Returns:
416
+ 生成的图像的字节数据
417
+ """
418
+ await self._ensure_client()
419
+ render_config = self.config.model_copy(update=config)
420
+
421
+ if isinstance(thread, Thread):
422
+ thread = convert_aiotieba_thread(thread)
423
+ elif isinstance(thread, Thread_p):
424
+ thread = convert_aiotieba_threadp(thread)
425
+
426
+ posts_dtos: list[PostDTO] = []
427
+ if posts:
428
+ for p in posts:
429
+ if isinstance(p, Post):
430
+ posts_dtos.append(convert_aiotieba_post(p))
431
+ else:
432
+ posts_dtos.append(p)
433
+
434
+ if ignore_first_floor:
435
+ posts_dtos = [p for p in posts_dtos if p.floor != 1]
436
+
437
+ thread_context = await self._build_content_context(thread, max_image_count, show_link=show_link)
438
+ posts_contexts = await asyncio.gather(*[
439
+ self._build_content_context(p, max_image_count, show_link=show_link) for p in posts_dtos
440
+ ])
441
+
442
+ if show_thread_info:
443
+ info_html = await self._render_html(
444
+ "thread_info.html",
445
+ {
446
+ "share_num": thread.share_num,
447
+ "agree_num": thread.agree_num,
448
+ "reply_num": thread.reply_num,
449
+ },
450
+ )
451
+ thread_context["sub_html_list"].append(info_html)
452
+
453
+ forum_icon_url = ""
454
+ if thread.fname:
455
+ forum_icon_url = self._get_forum_icon_url(thread.fname)
456
+
457
+ data = {
458
+ "thread": thread_context,
459
+ "posts": posts_contexts,
460
+ "forum": thread.fname,
461
+ "forum_icon_url": forum_icon_url,
462
+ "prefix_html": prefix_html or "",
463
+ "suffix_html": suffix_html or "",
464
+ "style_list": [get_font_style()],
465
+ }
466
+
467
+ image_bytes = await self._render_image("thread_detail.html", config=render_config, data=data)
468
+ return image_bytes
469
+
470
+ async def text_to_image(
471
+ self,
472
+ text: str,
473
+ *,
474
+ title: str = "",
475
+ header: str = "",
476
+ footer: str = "",
477
+ simple_mode: bool = False,
478
+ **config: Any,
479
+ ) -> bytes:
480
+ """
481
+ 将简单的文本渲染为图片
482
+
483
+ Args:
484
+ text: 要渲染的文本
485
+ title: 标题,可选,显示在头部下方的粗体大号字
486
+ header: 页眉,可选,显示在最上方的灰色小号字
487
+ footer: 页脚文本(如页码),可选,显示在最下方的灰色小号字
488
+ simple_mode: 是否使用极简紧凑样式,默认为 False
489
+ **config: 其他渲染配置参数
490
+
491
+ Returns:
492
+ 生成的图像的字节数据
493
+ """
494
+ render_config = self.config.model_copy(update=config)
495
+
496
+ template_name = "text_simple.html" if simple_mode else "text.html"
497
+
498
+ data = {
499
+ "text": text,
500
+ "title": title,
501
+ "header": header,
502
+ "footer": footer,
503
+ "style_list": [get_font_style()],
504
+ }
505
+
506
+ element = ".container" if simple_mode else None
507
+ image_bytes = await self._render_image(template_name, config=render_config, data=data, element=element)
508
+ return image_bytes