拆分文件、添加单日课表渲染功能
This commit is contained in:
7
_conf_schema.json
Normal file
7
_conf_schema.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"hide_void_columns_rows": {
|
||||||
|
"description": "隐藏课程表图片中的空行空列",
|
||||||
|
"type": "bool",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
511
main.py
511
main.py
@@ -1,18 +1,22 @@
|
|||||||
import os
|
"""AstrBot 课表渲染插件主模块"""
|
||||||
import tempfile
|
|
||||||
import subprocess
|
|
||||||
import base64
|
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
|
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
|
||||||
from astrbot.api.star import Context, Star
|
from astrbot.api.star import Context, Star
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
|
from astrbot.core import AstrBotConfig
|
||||||
from astrbot.core.message.message_event_result import MessageEventResult
|
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):
|
class SuanPlugin(Star):
|
||||||
def __init__(self, context: Context):
|
def __init__(self, context: Context, config: AstrBotConfig):
|
||||||
super().__init__(context)
|
super().__init__(context)
|
||||||
|
self.config = config
|
||||||
|
self.renderer = ScheduleRenderer(config)
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
|
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
|
||||||
@@ -41,478 +45,67 @@ class SuanPlugin(Star):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 生成 HTML 内容
|
# 生成 HTML 内容
|
||||||
html_content = self._generate_schedule_html(schedule_dict, title)
|
html_content = self.renderer.generate_html(schedule_dict, title)
|
||||||
|
|
||||||
# 额外保存一份到指定路径,用于样式调试
|
# 渲染为 PNG
|
||||||
with open('/home/fanfan/test.html', 'w', encoding='utf-8') as f:
|
image_data = await self.renderer.render_to_png(
|
||||||
f.write(html_content)
|
html_content,
|
||||||
|
debug_path='/home/fanfan/test.html'
|
||||||
# 创建临时文件
|
)
|
||||||
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
|
from astrbot.api.message_components import Image
|
||||||
yield event.chain_result([Image.fromBytes(image_data)])
|
yield event.chain_result([Image.fromBytes(image_data)])
|
||||||
else:
|
|
||||||
yield event.plain_result("生成课表图片失败,请检查 wkhtmltopdf 是否已安装")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"渲染课表失败: {e}")
|
logger.error(f"渲染课表失败: {e}")
|
||||||
yield event.plain_result(f"生成课表图片时出错: {str(e)}")
|
yield event.plain_result(f"生成课表图片时出错: {str(e)}")
|
||||||
|
|
||||||
async def _html_to_png_via_pdf(self, html_path: str) -> str:
|
@filter.llm_tool(name='render_daily_course_schedule_png')
|
||||||
"""通过 PDF 中转生成 PNG"""
|
async def render_daily_course_schedule_png(self, event: AstrMessageEvent, schedule: list[tuple], title: str, weekday: int = None) -> \
|
||||||
pdf_path = html_path.replace('.html', '.pdf')
|
AsyncGenerator[MessageEventResult, Any]:
|
||||||
png_path = html_path.replace('.html', '.png')
|
"""将单日课程表渲染为 png 格式的图片,以垂直卡片形式展示当天的课程安排。
|
||||||
|
|
||||||
# HTML -> PDF
|
与 render_course_schedule_png 不同,此工具只展示一天的课程,以卡片列表形式呈现,更适合查看特定日期的详细安排。
|
||||||
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:
|
Args:
|
||||||
raise RuntimeError(f"wkhtmltopdf 失败: {result.stderr}")
|
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]
|
||||||
|
|
||||||
# PDF -> PNG (使用 ImageMagick 的 convert)
|
title (str): 图片标题,如 "张三的课表"。
|
||||||
cmd = [
|
weekday (int, optional): 星期几 (1-7),用于在副标题显示。如为 None 则不显示星期信息。
|
||||||
'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 = [
|
schedule_dict = convert_tuple_schedule_to_dict(schedule)
|
||||||
'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}")
|
weekday_names = {1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日'}
|
||||||
|
weekday_name = weekday_names.get(weekday, '') if weekday else ''
|
||||||
|
|
||||||
# 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:
|
try:
|
||||||
if os.path.exists(path):
|
# 生成单日 HTML 内容
|
||||||
os.unlink(path)
|
html_content = self.renderer.generate_daily_html(schedule_dict, title, weekday_name)
|
||||||
|
|
||||||
|
# 渲染为 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:
|
except Exception as e:
|
||||||
logger.warning(f"清理临时文件失败 {path}: {e}")
|
logger.error(f"渲染单日课表失败: {e}")
|
||||||
|
yield event.plain_result(f"生成单日课表图片时出错: {str(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;
|
|
||||||
}}
|
|
||||||
|
|
||||||
body {{
|
|
||||||
font-family: "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
|
||||||
background: transparent;
|
|
||||||
padding: 40px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.container {{
|
|
||||||
width: 1600px;
|
|
||||||
background: #1a1a2e;
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 40px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.header {{
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
</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 <芒果酸> 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)
|
|
||||||
|
|
||||||
|
|
||||||
async def terminate(self):
|
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))
|
|
||||||
|
|
||||||
|
|||||||
594
schedule_renderer.py
Normal file
594
schedule_renderer.py
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
"""课表渲染模块 - 负责生成 HTML 和转换为图片"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from schedule_utils import get_period_time, format_weeks
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleRenderer:
|
||||||
|
"""课表渲染器"""
|
||||||
|
|
||||||
|
def __init__(self, config: dict = None):
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
def generate_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>第{format_weeks(weeks)}周</small>"
|
||||||
|
|
||||||
|
for class_idx in classes:
|
||||||
|
if 1 <= class_idx <= 12:
|
||||||
|
grid[class_idx - 1][day_idx].append(course_text)
|
||||||
|
|
||||||
|
# 判断是否隐藏空行空列
|
||||||
|
hide_void = self.config.get('hide_void_columns_rows', True)
|
||||||
|
|
||||||
|
# 计算有课的行和列
|
||||||
|
active_rows = set()
|
||||||
|
active_cols = set()
|
||||||
|
for row_idx in range(12):
|
||||||
|
for col_idx in range(7):
|
||||||
|
if grid[row_idx][col_idx]:
|
||||||
|
active_rows.add(row_idx)
|
||||||
|
active_cols.add(col_idx)
|
||||||
|
|
||||||
|
# 如果没有课,默认显示全部
|
||||||
|
if not active_rows:
|
||||||
|
active_rows = set(range(12))
|
||||||
|
if not active_cols:
|
||||||
|
active_cols = set(range(7))
|
||||||
|
|
||||||
|
# 转换为有序列表
|
||||||
|
active_rows = sorted(active_rows)
|
||||||
|
active_cols = sorted(active_cols)
|
||||||
|
|
||||||
|
# 如果不隐藏,显示全部
|
||||||
|
if not hide_void:
|
||||||
|
active_rows = list(range(12))
|
||||||
|
active_cols = list(range(7))
|
||||||
|
|
||||||
|
# 计算列宽:时间列90px,剩余宽度均分给各天
|
||||||
|
day_col_width = 14.28 if not hide_void else (100.0 / len(active_cols))
|
||||||
|
|
||||||
|
html = f'''<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
* {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
|
||||||
|
body {{
|
||||||
|
font-family: "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: transparent;
|
||||||
|
padding: 40px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.container {{
|
||||||
|
width: 1600px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header {{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
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: {day_col_width}%;
|
||||||
|
}}
|
||||||
|
|
||||||
|
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.15;
|
||||||
|
font-size: 17px;
|
||||||
|
height: 180px;
|
||||||
|
text-align: center;
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.course-item-inner {{
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col class="time-col">
|
||||||
|
{''.join('<col class="day-col">' for _ in active_cols)}
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>TIME</th>
|
||||||
|
{''.join(f'<th>{weekdays[i]}<span class="weekday-en">{weekday_short[i]}</span></th>' for i in active_cols)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{self._generate_table_body(grid, active_rows, active_cols)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="text-align: right; margin-top: 20px; font-size: 12px; color: #b0bec5;">Powered by <芒果酸> suan.mangofanfan.cn</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
def _generate_table_body(self, grid: list, active_rows: list, active_cols: list) -> str:
|
||||||
|
"""生成表格主体 HTML"""
|
||||||
|
rows = []
|
||||||
|
course_type_counter = 0
|
||||||
|
type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e']
|
||||||
|
|
||||||
|
for period in active_rows:
|
||||||
|
start_time = 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 active_cols:
|
||||||
|
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}"><div class="course-item-inner">{c}</div></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 generate_daily_html(self, schedule: list[dict], title: str, weekday_name: str = "") -> str:
|
||||||
|
"""生成单日课程表 HTML - 垂直卡片布局"""
|
||||||
|
|
||||||
|
# 按节次排序课程
|
||||||
|
sorted_courses = sorted(schedule, key=lambda x: min(x.get('classes', [99])) if x.get('classes') else 99)
|
||||||
|
|
||||||
|
type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e']
|
||||||
|
cards_html = []
|
||||||
|
course_type_counter = 0
|
||||||
|
|
||||||
|
for course in sorted_courses:
|
||||||
|
name = course.get('name', '未知课程')
|
||||||
|
teacher = course.get('teacher') or ''
|
||||||
|
classroom = course.get('classroom') or ''
|
||||||
|
weeks = course.get('weeks', [])
|
||||||
|
classes = course.get('classes', [])
|
||||||
|
|
||||||
|
if not classes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取第一节课的节次和时间
|
||||||
|
first_period = min(classes)
|
||||||
|
start_time = get_period_time(first_period)
|
||||||
|
end_time = get_period_time(max(classes))
|
||||||
|
|
||||||
|
# 构建课程信息文本
|
||||||
|
info_lines = []
|
||||||
|
if teacher:
|
||||||
|
info_lines.append(f"<div class='info-line'>👤 {teacher}</div>")
|
||||||
|
if classroom:
|
||||||
|
info_lines.append(f"<div class='info-line'>📍 {classroom}</div>")
|
||||||
|
if weeks:
|
||||||
|
info_lines.append(f"<div class='info-line'>📅 第{format_weeks(weeks)}周</div>")
|
||||||
|
|
||||||
|
type_class = type_classes[course_type_counter % len(type_classes)]
|
||||||
|
course_type_counter += 1
|
||||||
|
|
||||||
|
card_html = f'''
|
||||||
|
<div class="course-card {type_class}">
|
||||||
|
<div class="time-badge">
|
||||||
|
<div class="period-num">{first_period}</div>
|
||||||
|
<div class="time-range">{start_time}-{end_time}</div>
|
||||||
|
</div>
|
||||||
|
<div class="course-content">
|
||||||
|
<div class="course-name">{name}</div>
|
||||||
|
{''.join(info_lines)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
cards_html.append(card_html)
|
||||||
|
|
||||||
|
# 如果没有课程
|
||||||
|
if not cards_html:
|
||||||
|
cards_html.append('<div class="empty-message">今日暂无课程安排 🎉</div>')
|
||||||
|
|
||||||
|
subtitle = f"{weekday_name} 课程安排" if weekday_name else "课程安排"
|
||||||
|
|
||||||
|
html = f'''<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
* {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
|
||||||
|
body {{
|
||||||
|
font-family: "WenQuanYi Micro Hei", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: transparent;
|
||||||
|
padding: 40px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.container {{
|
||||||
|
width: 800px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header {{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
h1 {{
|
||||||
|
font-size: 36px;
|
||||||
|
color: #e0f7fa;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.subtitle {{
|
||||||
|
font-size: 20px;
|
||||||
|
color: #80deea;
|
||||||
|
margin-top: 8px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.cards-wrapper {{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.course-card {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #00838f;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 120px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.course-card.type-a {{ background: linear-gradient(135deg, #0277bd 0%, #01579b 100%); }}
|
||||||
|
.course-card.type-b {{ background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%); }}
|
||||||
|
.course-card.type-c {{ background: linear-gradient(135deg, #7b1fa2 0%, #4a148c 100%); }}
|
||||||
|
.course-card.type-d {{ background: linear-gradient(135deg, #c6278e 0%, #880e4f 100%); }}
|
||||||
|
.course-card.type-e {{ background: linear-gradient(135deg, #ef6c00 0%, #e65100 100%); }}
|
||||||
|
|
||||||
|
.time-badge {{
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
padding-right: 20px;
|
||||||
|
border-right: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
margin-right: 20px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.time-badge .period-num {{
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.time-badge .time-range {{
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-top: 4px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.course-content {{
|
||||||
|
flex: 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.course-name {{
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.info-line {{
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-top: 4px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.empty-message {{
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #80deea;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: #263238;
|
||||||
|
border-radius: 16px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.footer {{
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b0bec5;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<div class="subtitle">{subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cards-wrapper">
|
||||||
|
{''.join(cards_html)}
|
||||||
|
</div>
|
||||||
|
<div class="footer">Powered by <芒果酸> suan.mangofanfan.cn</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
async def render_to_png(self, html_content: str, debug_path: str = None, width: int = 1680) -> bytes:
|
||||||
|
"""将 HTML 内容渲染为 PNG 图片字节
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_content: HTML 内容字符串
|
||||||
|
debug_path: 调试文件保存路径(可选)
|
||||||
|
width: 输出图片宽度,默认 1680
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PNG 图片字节数据
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 额外保存一份到指定路径,用于样式调试
|
||||||
|
if debug_path:
|
||||||
|
try:
|
||||||
|
with open(debug_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html_content)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"保存调试文件失败: {e}")
|
||||||
|
|
||||||
|
# 创建临时文件
|
||||||
|
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 直接生成图片
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
'wkhtmltoimage',
|
||||||
|
'--width', str(width),
|
||||||
|
'--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)
|
||||||
|
|
||||||
|
# 读取图片数据
|
||||||
|
with open(png_path, 'rb') as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
self.cleanup_temp_files(html_path, png_path)
|
||||||
|
|
||||||
|
return image_data
|
||||||
124
schedule_utils.py
Normal file
124
schedule_utils.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""课表数据处理工具函数"""
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
def format_weeks(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)
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_time(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, '')
|
||||||
Reference in New Issue
Block a user