662 lines
21 KiB
Python
662 lines
21 KiB
Python
"""课表渲染模块 - 负责生成 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 <芒果酸> 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 <芒果酸> 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
|