"""课表渲染模块 - 负责生成 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"{name}" if teacher: course_text += f"
{teacher}" if classroom: course_text += f"
@{classroom}" if weeks: course_text += f"
第{format_weeks(weeks)}周" 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''' {title}

{title}

{''.join('' for _ in active_cols)} {''.join(f'' for i in active_cols)} {self._generate_table_body(grid, active_rows, active_cols)}
TIME{weekdays[i]}{weekday_short[i]}
Powered by <芒果酸> suan.mangofanfan.cn
''' 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''' {period + 1} {start_time} ''' 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'
{c}
' else: cell_content = '' row_html += f'{cell_content}' row_html += '' 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"
👤 {teacher}
") if classroom: info_lines.append(f"
📍 {classroom}
") if weeks: info_lines.append(f"
📅 第{format_weeks(weeks)}周
") type_class = type_classes[course_type_counter % len(type_classes)] course_type_counter += 1 card_html = f'''
{first_period}
{start_time}-{end_time}
{name}
{''.join(info_lines)}
''' cards_html.append(card_html) # 如果没有课程 if not cards_html: cards_html.append('
今日暂无课程安排 🎉
') subtitle = f"{weekday_name} 课程安排" if weekday_name else "课程安排" html = f''' {title}

{title}

{subtitle}
{''.join(cards_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