"""课表渲染模块 - 负责生成 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"{name}" if teacher: course_text += f"
{teacher}" if classroom: course_text += f"
@{classroom}" if weeks: course_text += f"
第{weeks_str}周" # 填充到 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''' {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(支持 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''' {period + 1} {start_time} ''' 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'
{course_text}
' else: row_html += f'' 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'] 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"
👤 {teacher}
") if classroom: info_lines.append(f"
📍 {classroom}
") if weeks: info_lines.append(f"
📅 第{format_weeks(weeks)}周
") row_html = f'''
{period_display}
{start_time}-{end_time}
{name}
{''.join(info_lines)}
''' rows_html.append(row_html) # 如果没有课程 if not rows_html: table_body = '今日暂无课程安排 🎉' else: table_body = '\n'.join(rows_html) subtitle = f"{weekday_name} 课程安排" if weekday_name else "课程安排" html = f''' {title}

{title}

{subtitle}
{table_body}
''' 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