Files
astrbot_plugin_njupt_suan/schedule_renderer.py
2026-04-02 23:16:08 +08:00

662 lines
21 KiB
Python
Raw Permalink 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支持 rowspan 合并连续节次)"""
weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
weekday_short = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
# grid 存储 (course_key, html_content) 或 None被合并的单元格
# course_key 用于识别同一门课name|teacher|classroom|weeks_str
grid = [[None 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 = sorted(course.get('classes', []))
day_idx = day - 1
if day_idx < 0 or day_idx > 6 or not classes:
continue
# 生成课程 key 和 HTML 内容
weeks_str = format_weeks(weeks)
course_key = f"{name}|{teacher}|{classroom}|{weeks_str}"
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>第{weeks_str}周</small>"
# 填充到 grid只填第一个节次
first_period = min(classes)
if 1 <= first_period <= 12:
grid[first_period - 1][day_idx] = (course_key, course_text, len(classes))
# 判断是否隐藏空行空列
hide_void = self.config.get('hide_void_columns_rows', True)
# 计算有课的行和列(考虑 rowspan
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)
# rowspan 占据的行也算有课
rowspan = grid[row_idx][col_idx][2]
for r in range(row_idx, min(row_idx + rowspan, 12)):
active_rows.add(r)
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;
height: 190px;
box-sizing: border-box;
}}
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: 190px;
box-sizing: border-box;
}}
.course-item {{
background: #00838f;
border-radius: 10px;
padding: 6px;
margin: 0;
color: #e0f7fa;
line-height: 1.15;
font-size: 17px;
height: 100%;
text-align: center;
display: table;
width: 100%;
box-sizing: border-box;
}}
.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支持 rowspan"""
rows = []
course_type_counter = 0
type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e']
# 跟踪哪些单元格被 rowspan 占用了
# occupied[row][col] = True 表示该单元格被上方的 rowspan 占用
occupied = [[False for _ in range(7)] for _ in range(12)]
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:
# 检查该单元格是否被上方的 rowspan 占用
if occupied[period][day]:
continue
cell_data = grid[period][day]
if cell_data:
course_key, course_text, rowspan = cell_data
type_class = type_classes[course_type_counter % len(type_classes)]
course_type_counter += 1
# 标记下方被占用的单元格
for r in range(period, min(period + rowspan, 12)):
occupied[r][day] = True
row_html += f'<td class="day-cell" rowspan="{rowspan}"><div class="course-item {type_class}"><div class="course-item-inner">{course_text}</div></div></td>'
else:
row_html += f'<td class="day-cell"></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']
course_type_counter = 0
rows_html = []
for course in sorted_courses:
name = course.get('name', '未知课程')
teacher = course.get('teacher') or ''
classroom = course.get('classroom') or ''
weeks = course.get('weeks', [])
classes = sorted(course.get('classes', []))
if not classes:
continue
first_period = min(classes)
last_period = max(classes)
period_count = len(classes)
start_time = get_period_time(first_period)
end_time = get_period_time(last_period)
# 节次显示
if first_period == last_period:
period_display = str(first_period)
else:
period_display = f"{first_period}-{last_period}"
# 计算行高基础120px每多一节+100px
row_height = 120 + (period_count - 1) * 100
type_class = type_classes[course_type_counter % len(type_classes)]
course_type_counter += 1
# 构建课程信息
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>")
row_html = f'''
<tr style="height: {row_height}px;">
<td class="time-cell">
<div class="time-inner">
<div class="period-num">{period_display}</div>
<div class="time-range">{start_time}-{end_time}</div>
</div>
</td>
<td class="course-cell {type_class}">
<div class="course-inner">
<div class="course-name">{name}</div>
{''.join(info_lines)}
</div>
</td>
</tr>
'''
rows_html.append(row_html)
# 如果没有课程
if not rows_html:
table_body = '<tr><td colspan="2" class="empty-cell">今日暂无课程安排 🎉</td></tr>'
else:
table_body = '\n'.join(rows_html)
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: 600px;
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;
}}
table {{
width: 100%;
border-collapse: separate;
border-spacing: 0 12px;
}}
tr {{
height: 120px;
}}
.time-cell {{
width: 100px;
padding: 0;
vertical-align: middle;
}}
.time-inner {{
background: #00796b;
border-radius: 12px;
padding: 15px 10px;
text-align: center;
height: 100%;
display: table;
width: 100%;
}}
.time-inner > div {{
display: table-cell;
vertical-align: middle;
}}
.time-cell .period-num {{
font-size: 32px;
font-weight: bold;
color: #ffffff;
display: block;
}}
.time-cell .time-range {{
font-size: 13px;
color: #b2ebf2;
margin-top: 4px;
display: block;
}}
.course-cell {{
padding: 0 0 0 12px;
vertical-align: middle;
}}
.course-inner {{
background: #00838f;
border-radius: 12px;
padding: 20px;
height: 100%;
display: table;
width: 100%;
}}
.course-cell.type-a .course-inner {{ background: linear-gradient(135deg, #0277bd 0%, #01579b 100%); }}
.course-cell.type-b .course-inner {{ background: linear-gradient(135deg, #2e7d32 0%, #1b5e20 100%); }}
.course-cell.type-c .course-inner {{ background: linear-gradient(135deg, #7b1fa2 0%, #4a148c 100%); }}
.course-cell.type-d .course-inner {{ background: linear-gradient(135deg, #c6278e 0%, #880e4f 100%); }}
.course-cell.type-e .course-inner {{ background: linear-gradient(135deg, #ef6c00 0%, #e65100 100%); }}
.course-inner > div {{
display: table-cell;
vertical-align: middle;
}}
.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-cell {{
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>
<table>
<tbody>
{table_body}
</tbody>
</table>
<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