拆分文件、添加单日课表渲染功能

This commit is contained in:
2026-04-02 21:23:09 +08:00
parent fd948ca897
commit 868abb2358
4 changed files with 783 additions and 465 deletions

523
main.py
View File

@@ -1,18 +1,22 @@
import os
import tempfile
import subprocess
import base64
"""AstrBot 课表渲染插件主模块"""
from typing import Any, AsyncGenerator
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
from astrbot.api.star import Context, Star
from astrbot.api import logger
from astrbot.core import AstrBotConfig
from astrbot.core.message.message_event_result import MessageEventResult
from schedule_utils import convert_tuple_schedule_to_dict
from schedule_renderer import ScheduleRenderer
class SuanPlugin(Star):
def __init__(self, context: Context):
def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context)
self.config = config
self.renderer = ScheduleRenderer(config)
async def initialize(self):
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
@@ -41,478 +45,67 @@ class SuanPlugin(Star):
try:
# 生成 HTML 内容
html_content = self._generate_schedule_html(schedule_dict, title)
html_content = self.renderer.generate_html(schedule_dict, title)
# 额外保存一份到指定路径,用于样式调试
with open('/home/fanfan/test.html', 'w', encoding='utf-8') as f:
f.write(html_content)
# 创建临时文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f:
f.write(html_content)
html_path = f.name
# 生成 PNG 输出路径
png_path = html_path.replace('.html', '.png')
# 使用 wkhtmltoimage 直接生成图片(比 wkhtmltopdf 再转换更方便)
# 如果系统只有 wkhtmltopdf可以先生成 PDF 再转换
try:
# 尝试使用 wkhtmltoimage如果安装了
cmd = [
'wkhtmltoimage',
'--width', '1680',
'--quality', '90',
'--enable-local-file-access',
'--transparent',
html_path,
png_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0 or not os.path.exists(png_path):
# 回退到 wkhtmltopdf + convert 方案
png_path = await self._html_to_png_via_pdf(html_path)
except FileNotFoundError:
# wkhtmltoimage 不存在,使用 pdf 方案
png_path = await self._html_to_png_via_pdf(html_path)
# 读取图片并返回
if os.path.exists(png_path):
with open(png_path, 'rb') as f:
image_data = f.read()
# 清理临时文件
self._cleanup_temp_files(html_path, png_path)
# 返回图片消息
from astrbot.api.message_components import Image
yield event.chain_result([Image.fromBytes(image_data)])
else:
yield event.plain_result("生成课表图片失败,请检查 wkhtmltopdf 是否已安装")
# 渲染为 PNG
image_data = await self.renderer.render_to_png(
html_content,
debug_path='/home/fanfan/test.html'
)
# 返回图片消息
from astrbot.api.message_components import Image
yield event.chain_result([Image.fromBytes(image_data)])
except Exception as e:
logger.error(f"渲染课表失败: {e}")
yield event.plain_result(f"生成课表图片时出错: {str(e)}")
async def _html_to_png_via_pdf(self, html_path: str) -> str:
"""通过 PDF 中转生成 PNG"""
pdf_path = html_path.replace('.html', '.pdf')
png_path = html_path.replace('.html', '.png')
# HTML -> PDF
cmd = [
'wkhtmltopdf',
'--page-width', '297mm', # A4 横向
'--page-height', '210mm',
'--encoding', 'utf-8',
'--enable-local-file-access',
'--no-stop-slow-scripts',
'--javascript-delay', '1000',
html_path,
pdf_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"wkhtmltopdf 失败: {result.stderr}")
# PDF -> PNG (使用 ImageMagick 的 convert)
cmd = [
'convert',
'-density', '150',
pdf_path,
'-quality', '90',
png_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
# 尝试使用 pdftoppm
cmd = [
'pdftoppm',
'-png',
'-r', '150',
'-singlefile',
pdf_path,
png_path.replace('.png', '')
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"PDF 转 PNG 失败: {result.stderr}")
# pdftoppm 生成的文件名可能不同
generated_png = png_path.replace('.png', '-1.png')
if os.path.exists(generated_png):
os.rename(generated_png, png_path)
# 清理 PDF 临时文件
if os.path.exists(pdf_path):
os.unlink(pdf_path)
return png_path
def _cleanup_temp_files(self, html_path: str, png_path: str):
"""清理临时文件"""
for path in [html_path, png_path]:
try:
if os.path.exists(path):
os.unlink(path)
except Exception as e:
logger.warning(f"清理临时文件失败 {path}: {e}")
def _generate_schedule_html(self, schedule: list[dict], title: str) -> str:
"""生成课程表 HTML - 清新薄荷绿风格(静态图片版)"""
weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
weekday_short = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
grid = [[[] for _ in range(7)] for _ in range(12)]
for course in schedule:
name = course.get('name', '未知课程')
teacher = course.get('teacher') or ''
classroom = course.get('classroom') or ''
weeks = course.get('weeks', [])
day = course.get('day', 1)
classes = course.get('classes', [])
day_idx = day - 1
if day_idx < 0 or day_idx > 6:
continue
course_text = f"<b>{name}</b>"
if teacher:
course_text += f"<br><small>{teacher}</small>"
if classroom:
course_text += f"<br><small>@{classroom}</small>"
if weeks:
course_text += f"<br><small>第{self._format_weeks(weeks)}周</small>"
for class_idx in classes:
if 1 <= class_idx <= 12:
grid[class_idx - 1][day_idx].append(course_text)
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
@filter.llm_tool(name='render_daily_course_schedule_png')
async def render_daily_course_schedule_png(self, event: AstrMessageEvent, schedule: list[tuple], title: str, weekday: int = None) -> \
AsyncGenerator[MessageEventResult, Any]:
"""将单日课程表渲染为 png 格式的图片,以垂直卡片形式展示当天的课程安排。
body {{
font-family: "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
background: transparent;
padding: 40px;
}}
与 render_course_schedule_png 不同,此工具只展示一天的课程,以卡片列表形式呈现,更适合查看特定日期的详细安排。
.container {{
width: 1600px;
background: #1a1a2e;
border-radius: 20px;
padding: 40px;
}}
Args:
schedule (list[tuple]): 包含数个元组的列表。每个元组的内部是按照顺序的如下元素:
0. str: 课程名称
1. str: 授课教师(可以为 None
2. str: 教室(可以为 None
3. str | list: 形如 `1-17` 表示从第 1 周到第 17 周list[int] 也是受支持的。
4. int: 星期几int (1-7),此参数在单日视图中可选
5. list: 当日第几节课list[int]
title (str): 图片标题,如 "张三的课表"
weekday (int, optional): 星期几 (1-7),用于在副标题显示。如为 None 则不显示星期信息。
"""
# 转换为字典格式
schedule_dict = convert_tuple_schedule_to_dict(schedule)
.header {{
text-align: center;
margin-bottom: 30px;
}}
# 获取星期名称
weekday_names = {1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日'}
weekday_name = weekday_names.get(weekday, '') if weekday else ''
h1 {{
font-size: 32px;
color: #e0f7fa;
letter-spacing: 2px;
}}
table {{
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 10px;
font-size: 14px;
}}
col.time-col {{
width: 90px;
}}
col.day-col {{
width: 14.28%;
}}
th {{
background: #006064;
color: #e0f7fa;
font-weight: bold;
padding: 15px 10px;
border-radius: 12px;
}}
th .weekday-en {{
font-size: 11px;
display: block;
margin-top: 4px;
color: #80deea;
}}
td.time-col {{
background: #00796b;
color: #e0f7fa;
font-weight: bold;
padding: 15px 10px;
border-radius: 12px;
text-align: center;
}}
td.time-col .period-num {{
font-size: 18px;
display: block;
}}
td.time-col .period-time {{
font-size: 11px;
display: block;
margin-top: 4px;
color: #b2ebf2;
}}
td.day-cell {{
background: #263238;
border-radius: 12px;
padding: 10px;
vertical-align: top;
height: 110px;
}}
.course-item {{
background: #00838f;
border-radius: 10px;
padding: 6px;
margin: 2px 0;
color: #e0f7fa;
line-height: 1.1;
font-size: 17px;
height: 180px;
}}
.course-item b {{
display: block;
font-size: 20px;
font-weight: bold;
margin-bottom: 1px;
color: #ffffff;
}}
.course-item small {{
display: block;
font-size: 14px;
color: #b2ebf2;
}}
.course-item small + small {{
margin-top: 0px;
}}
.course-item.type-a {{ background: #0277bd; }}
.course-item.type-b {{ background: #2e7d32; }}
.course-item.type-c {{ background: #7b1fa2; }}
.course-item.type-d {{ background: #c6278e; }}
.course-item.type-e {{ background: #ef6c00; }}
</style>
try:
# 生成单日 HTML 内容
html_content = self.renderer.generate_daily_html(schedule_dict, title, weekday_name)
</head>
<body>
<div class="container">
<div class="header">
<h1>{title}</h1>
</div>
<table>
<colgroup>
<col class="time-col">
<col class="day-col"><col class="day-col"><col class="day-col">
<col class="day-col"><col class="day-col"><col class="day-col">
<col class="day-col">
</colgroup>
<thead>
<tr>
<th>TIME</th>
{''.join(f'<th>{w}<span class="weekday-en">{weekday_short[i]}</span></th>' for i, w in enumerate(weekdays))}
</tr>
</thead>
<tbody>
{self._generate_table_body(grid)}
</tbody>
</table>
<div style="text-align: right; margin-top: 20px; font-size: 12px; color: #b0bec5;">Powered by &lt;芒果酸&gt; suan.mangofanfan.cn</div>
</div>
</body>
</html>'''
return html
def _generate_table_body(self, grid: list) -> str:
"""生成表格主体 HTML"""
rows = []
course_type_counter = 0
type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e']
for period in range(12):
start_time = self._get_period_time(period + 1)
row_html = f'''<tr>
<td class="time-col">
<span class="period-num">{period + 1}</span>
<span class="period-time">{start_time}</span>
</td>'''
for day in range(7):
courses = grid[period][day]
if courses:
cell_content = ''
for c in courses:
type_class = type_classes[course_type_counter % len(type_classes)]
course_type_counter += 1
cell_content += f'<div class="course-item {type_class}">{c}</div>'
else:
cell_content = ''
row_html += f'<td class="day-cell">{cell_content}</td>'
row_html += '</tr>'
rows.append(row_html)
return '\n'.join(rows)
def _get_period_time(self, period: int) -> str:
"""获取节次对应的时间(南邮标准时间)"""
# 南邮标准作息时间,可根据需要调整
time_map = {
1: '08:00',
2: '08:50',
3: '09:50',
4: '10:40',
5: '11:30',
6: '13:45',
7: '14:35',
8: '15:35',
9: '16:25',
10: '18:30',
11: '19:20',
12: '20:10'
}
return time_map.get(period, '')
def _format_weeks(self, weeks: list[int]) -> str:
"""格式化周数显示,如 [1,2,3,5,6,7] -> "1-3,5-7" """
if not weeks:
return ''
weeks = sorted(set(weeks))
ranges = []
start = end = weeks[0]
for w in weeks[1:]:
if w == end + 1:
end = w
else:
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
start = end = w
if start == end:
ranges.append(str(start))
else:
ranges.append(f"{start}-{end}")
return ','.join(ranges)
# 渲染为 PNG使用较小的宽度适合单列卡片
image_data = await self.renderer.render_to_png(
html_content,
debug_path='/home/fanfan/test_daily.html',
width=880
)
# 返回图片消息
from astrbot.api.message_components import Image
yield event.chain_result([Image.fromBytes(image_data)])
except Exception as e:
logger.error(f"渲染单日课表失败: {e}")
yield event.plain_result(f"生成单日课表图片时出错: {str(e)}")
async def terminate(self):
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
def convert_tuple_schedule_to_dict(schedule: list[tuple]) -> list[dict]:
"""将元组格式的课表转换为字典格式。
Args:
schedule: list[tuple],每个元组包含 (name, teacher, classroom, weeks, day, classes)
其中 weeks 可以是 list[int] 或 str"1-17"
Returns:
list[dict]: 标准格式的课程数据
"""
result = []
for course in schedule:
# 确保元组长度足够,不足的补 None
padded = list(course) + [None] * (6 - len(course))
name, teacher, classroom, weeks, day, classes = padded[:6]
# 处理 weeks支持 list 或 str如 "1-17", "1,3,5-10"
weeks_list = []
if isinstance(weeks, list):
weeks_list = weeks
elif isinstance(weeks, str):
weeks_list = parse_weeks_string(weeks)
result.append({
"name": name,
"teacher": teacher,
"classroom": classroom,
"weeks": weeks_list,
"day": day if isinstance(day, int) else 1,
"classes": classes if isinstance(classes, list) else []
})
return result
def parse_weeks_string(weeks_str: str) -> list[int]:
"""将周数字符串解析为整数列表。
支持格式:
"1-17" -> [1,2,3,...,17]
"1,3,5" -> [1,3,5]
"1-5,7,9-11" -> [1,2,3,4,5,7,9,10,11]
Args:
weeks_str: 周数字符串
Returns:
list[int]: 周数列表
"""
if not weeks_str or not isinstance(weeks_str, str):
return []
result = []
parts = weeks_str.split(',')
for part in parts:
part = part.strip()
if '-' in part:
# 范围格式1-17
try:
start, end = part.split('-', 1)
start = int(start.strip())
end = int(end.strip())
result.extend(range(start, end + 1))
except ValueError:
continue
else:
# 单个数字
try:
result.append(int(part))
except ValueError:
continue
# 去重并排序
return sorted(set(result))