diff --git a/_conf_schema.json b/_conf_schema.json new file mode 100644 index 0000000..22ee34a --- /dev/null +++ b/_conf_schema.json @@ -0,0 +1,7 @@ +{ + "hide_void_columns_rows": { + "description": "隐藏课程表图片中的空行空列", + "type": "bool", + "default": true + } +} diff --git a/main.py b/main.py index 2c44152..9ac3429 100644 --- a/main.py +++ b/main.py @@ -1,18 +1,22 @@ -import os -import tempfile -import subprocess -import base64 +"""AstrBot 课表渲染插件主模块""" + from typing import Any, AsyncGenerator from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult from astrbot.api.star import Context, Star from astrbot.api import logger +from astrbot.core import AstrBotConfig from astrbot.core.message.message_event_result import MessageEventResult +from schedule_utils import convert_tuple_schedule_to_dict +from schedule_renderer import ScheduleRenderer + class SuanPlugin(Star): - def __init__(self, context: Context): + def __init__(self, context: Context, config: AstrBotConfig): super().__init__(context) + self.config = config + self.renderer = ScheduleRenderer(config) async def initialize(self): """可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。""" @@ -41,478 +45,67 @@ class SuanPlugin(Star): try: # 生成 HTML 内容 - html_content = self._generate_schedule_html(schedule_dict, title) + html_content = self.renderer.generate_html(schedule_dict, title) - # 额外保存一份到指定路径,用于样式调试 - with open('/home/fanfan/test.html', 'w', encoding='utf-8') as f: - f.write(html_content) - - # 创建临时文件 - 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 直接生成图片(比 wkhtmltopdf 再转换更方便) - # 如果系统只有 wkhtmltopdf,可以先生成 PDF 再转换 - try: - # 尝试使用 wkhtmltoimage(如果安装了) - cmd = [ - 'wkhtmltoimage', - '--width', '1680', - '--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) - - # 读取图片并返回 - if os.path.exists(png_path): - with open(png_path, 'rb') as f: - image_data = f.read() - - # 清理临时文件 - self._cleanup_temp_files(html_path, png_path) - - # 返回图片消息 - from astrbot.api.message_components import Image - yield event.chain_result([Image.fromBytes(image_data)]) - else: - yield event.plain_result("生成课表图片失败,请检查 wkhtmltopdf 是否已安装") + # 渲染为 PNG + image_data = await self.renderer.render_to_png( + html_content, + debug_path='/home/fanfan/test.html' + ) + + # 返回图片消息 + from astrbot.api.message_components import Image + yield event.chain_result([Image.fromBytes(image_data)]) except Exception as e: logger.error(f"渲染课表失败: {e}") yield event.plain_result(f"生成课表图片时出错: {str(e)}") - 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}") - - def _generate_schedule_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"
第{self._format_weeks(weeks)}周" - - for class_idx in classes: - if 1 <= class_idx <= 12: - grid[class_idx - 1][day_idx].append(course_text) - - html = f''' - - - - {title} - + try: + # 生成单日 HTML 内容 + html_content = self.renderer.generate_daily_html(schedule_dict, title, weekday_name) - - -
-
-

{title}

-
- - - - - - - - - - - {''.join(f'' for i, w in enumerate(weekdays))} - - - - {self._generate_table_body(grid)} - -
TIME{w}{weekday_short[i]}
-
Powered by <芒果酸> suan.mangofanfan.cn
-
- -''' - - return html - - def _generate_table_body(self, grid: list) -> str: - """生成表格主体 HTML""" - rows = [] - course_type_counter = 0 - type_classes = ['type-a', 'type-b', 'type-c', 'type-d', 'type-e'] - - for period in range(12): - start_time = self._get_period_time(period + 1) - - row_html = f''' - - {period + 1} - {start_time} - ''' - - for day in range(7): - 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 _get_period_time(self, period: int) -> str: - """获取节次对应的时间(南邮标准时间)""" - # 南邮标准作息时间,可根据需要调整 - time_map = { - 1: '08:00', - 2: '08:50', - 3: '09:50', - 4: '10:40', - 5: '11:30', - 6: '13:45', - 7: '14:35', - 8: '15:35', - 9: '16:25', - 10: '18:30', - 11: '19:20', - 12: '20:10' - } - return time_map.get(period, '') - - def _format_weeks(self, weeks: list[int]) -> str: - """格式化周数显示,如 [1,2,3,5,6,7] -> "1-3,5-7" """ - if not weeks: - return '' - - weeks = sorted(set(weeks)) - ranges = [] - start = end = weeks[0] - - for w in weeks[1:]: - if w == end + 1: - end = w - else: - if start == end: - ranges.append(str(start)) - else: - ranges.append(f"{start}-{end}") - start = end = w - - if start == end: - ranges.append(str(start)) - else: - ranges.append(f"{start}-{end}") - - return ','.join(ranges) + # 渲染为 PNG(使用较小的宽度适合单列卡片) + image_data = await self.renderer.render_to_png( + html_content, + debug_path='/home/fanfan/test_daily.html', + width=880 + ) + + # 返回图片消息 + from astrbot.api.message_components import Image + yield event.chain_result([Image.fromBytes(image_data)]) + except Exception as e: + logger.error(f"渲染单日课表失败: {e}") + yield event.plain_result(f"生成单日课表图片时出错: {str(e)}") async def terminate(self): """可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。""" - - -def convert_tuple_schedule_to_dict(schedule: list[tuple]) -> list[dict]: - """将元组格式的课表转换为字典格式。 - - Args: - schedule: list[tuple],每个元组包含 (name, teacher, classroom, weeks, day, classes) - 其中 weeks 可以是 list[int] 或 str(如 "1-17") - - Returns: - list[dict]: 标准格式的课程数据 - """ - result = [] - for course in schedule: - # 确保元组长度足够,不足的补 None - padded = list(course) + [None] * (6 - len(course)) - name, teacher, classroom, weeks, day, classes = padded[:6] - - # 处理 weeks:支持 list 或 str(如 "1-17", "1,3,5-10") - weeks_list = [] - if isinstance(weeks, list): - weeks_list = weeks - elif isinstance(weeks, str): - weeks_list = parse_weeks_string(weeks) - - result.append({ - "name": name, - "teacher": teacher, - "classroom": classroom, - "weeks": weeks_list, - "day": day if isinstance(day, int) else 1, - "classes": classes if isinstance(classes, list) else [] - }) - - return result - - -def parse_weeks_string(weeks_str: str) -> list[int]: - """将周数字符串解析为整数列表。 - - 支持格式: - "1-17" -> [1,2,3,...,17] - "1,3,5" -> [1,3,5] - "1-5,7,9-11" -> [1,2,3,4,5,7,9,10,11] - - Args: - weeks_str: 周数字符串 - - Returns: - list[int]: 周数列表 - """ - if not weeks_str or not isinstance(weeks_str, str): - return [] - - result = [] - parts = weeks_str.split(',') - - for part in parts: - part = part.strip() - if '-' in part: - # 范围格式:1-17 - try: - start, end = part.split('-', 1) - start = int(start.strip()) - end = int(end.strip()) - result.extend(range(start, end + 1)) - except ValueError: - continue - else: - # 单个数字 - try: - result.append(int(part)) - except ValueError: - continue - - # 去重并排序 - return sorted(set(result)) - diff --git a/schedule_renderer.py b/schedule_renderer.py new file mode 100644 index 0000000..0235203 --- /dev/null +++ b/schedule_renderer.py @@ -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"{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 diff --git a/schedule_utils.py b/schedule_utils.py new file mode 100644 index 0000000..ccae31e --- /dev/null +++ b/schedule_utils.py @@ -0,0 +1,124 @@ +"""课表数据处理工具函数""" + + +def convert_tuple_schedule_to_dict(schedule: list[tuple]) -> list[dict]: + """将元组格式的课表转换为字典格式。 + + Args: + schedule: list[tuple],每个元组包含 (name, teacher, classroom, weeks, day, classes) + 其中 weeks 可以是 list[int] 或 str(如 "1-17") + + Returns: + list[dict]: 标准格式的课程数据 + """ + result = [] + for course in schedule: + # 确保元组长度足够,不足的补 None + padded = list(course) + [None] * (6 - len(course)) + name, teacher, classroom, weeks, day, classes = padded[:6] + + # 处理 weeks:支持 list 或 str(如 "1-17", "1,3,5-10") + weeks_list = [] + if isinstance(weeks, list): + weeks_list = weeks + elif isinstance(weeks, str): + weeks_list = parse_weeks_string(weeks) + + result.append({ + "name": name, + "teacher": teacher, + "classroom": classroom, + "weeks": weeks_list, + "day": day if isinstance(day, int) else 1, + "classes": classes if isinstance(classes, list) else [] + }) + + return result + + +def parse_weeks_string(weeks_str: str) -> list[int]: + """将周数字符串解析为整数列表。 + + 支持格式: + "1-17" -> [1,2,3,...,17] + "1,3,5" -> [1,3,5] + "1-5,7,9-11" -> [1,2,3,4,5,7,9,10,11] + + Args: + weeks_str: 周数字符串 + + Returns: + list[int]: 周数列表 + """ + if not weeks_str or not isinstance(weeks_str, str): + return [] + + result = [] + parts = weeks_str.split(',') + + for part in parts: + part = part.strip() + if '-' in part: + # 范围格式:1-17 + try: + start, end = part.split('-', 1) + start = int(start.strip()) + end = int(end.strip()) + result.extend(range(start, end + 1)) + except ValueError: + continue + else: + # 单个数字 + try: + result.append(int(part)) + except ValueError: + continue + + # 去重并排序 + return sorted(set(result)) + + +def format_weeks(weeks: list[int]) -> str: + """格式化周数显示,如 [1,2,3,5,6,7] -> "1-3,5-7" """ + if not weeks: + return '' + + weeks = sorted(set(weeks)) + ranges = [] + start = end = weeks[0] + + for w in weeks[1:]: + if w == end + 1: + end = w + else: + if start == end: + ranges.append(str(start)) + else: + ranges.append(f"{start}-{end}") + start = end = w + + if start == end: + ranges.append(str(start)) + else: + ranges.append(f"{start}-{end}") + + return ','.join(ranges) + + +def get_period_time(period: int) -> str: + """获取节次对应的时间(南邮标准时间)""" + time_map = { + 1: '08:00', + 2: '08:50', + 3: '09:50', + 4: '10:40', + 5: '11:30', + 6: '13:45', + 7: '14:35', + 8: '15:35', + 9: '16:25', + 10: '18:30', + 11: '19:20', + 12: '20:10' + } + return time_map.get(period, '')