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

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

594
schedule_renderer.py Normal file
View 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 &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