Files
astrbot_plugin_njupt_suan/schedule_renderer.py

595 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""课表渲染模块 - 负责生成 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 &lt;芒果酸&gt; 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 &lt;芒果酸&gt; 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