diff --git a/.autograde/generate_pdf_report.py b/.autograde/generate_pdf_report.py index 0fbb263..e58ee3b 100644 --- a/.autograde/generate_pdf_report.py +++ b/.autograde/generate_pdf_report.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 """ -生成 PDF 成绩报告 +生成专业的 PDF 成绩报告 -合并 REPORT.md、FRONTEND.md、成绩摘要,生成完整的 PDF 报告 -使用 weasyprint 进行 HTML→PDF 转换 +适用于打印归档,包含: +- 封面页(课程信息、学生信息) +- 后端开发反思报告 +- 前端开发反思报告 +- 评分详情页 """ import argparse @@ -17,6 +20,7 @@ from pathlib import Path try: import markdown from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration HAS_PDF_SUPPORT = True except ImportError: HAS_PDF_SUPPORT = False @@ -42,101 +46,17 @@ def read_file(filepath): return "" -def generate_grade_summary(final_grade): - """生成成绩摘要 Markdown""" - total = final_grade.get("total_score", 0) - max_score = final_grade.get("max_score", 100) - breakdown = final_grade.get("breakdown", {}) - - lines = [ - "# 📊 成绩报告", - "", - f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')}", - "", - f"## 总分: {total:.2f} / {max_score}", - "", - "### 分项成绩", - "", - "| 项目 | 得分 | 满分 |", - "|------|------|------|", - ] - - # 编程测试 - prog = breakdown.get("programming", {}) - if prog: - lines.append(f"| 编程测试 | {prog.get('score', 0):.2f} | {prog.get('max_score', 80)} |") - - # REPORT.md - report = breakdown.get("report", {}) - if report: - lines.append(f"| REPORT.md | {report.get('score', 0):.2f} | {report.get('max_score', 10)} |") - - # FRONTEND.md - frontend = breakdown.get("frontend", {}) - if frontend: - lines.append(f"| FRONTEND.md | {frontend.get('score', 0):.2f} | {frontend.get('max_score', 10)} |") - - # 编程测试详情 - if prog.get("groups"): - lines.extend([ - "", - "### 编程测试详情", - "", - "| 分组 | 通过 | 总数 | 得分 | 满分 |", - "|------|------|------|------|------|", - ]) - for group_name, group_info in prog["groups"].items(): - lines.append( - f"| {group_name} | {group_info.get('passed', 0)} | " - f"{group_info.get('total', 0)} | {group_info.get('score', 0):.2f} | " - f"{group_info.get('max_score', 0)} |" - ) - - # LLM 评分详情 - for section_key, section_title in [ - ("report", "📝 REPORT.md 评分详情"), - ("frontend", "🎨 FRONTEND.md 评分详情") - ]: - section = breakdown.get(section_key, {}) - criteria = section.get("criteria", []) - if criteria: - lines.extend([ - "", - f"### {section_title}", - "", - "| 评分项 | 得分 | 评语 |", - "|--------|------|------|", - ]) - for c in criteria: - reason = c.get("reason", "").replace("|", "|").replace("\n", " ")[:80] - lines.append(f"| {c.get('id', '')} | {c.get('score', 0)} | {reason} |") - - confidence = section.get("confidence") - if confidence: - lines.append(f"\n*置信度: {confidence:.2f}*") - - flags = section.get("flags", []) - if flags: - lines.append(f"\n*标记: {', '.join(flags)}*") - - lines.extend(["", "---", ""]) - - return "\n".join(lines) - - def fix_image_paths(content, images_dir): - """修复图片路径为绝对路径(用于 PDF 生成)""" + """修复图片路径为绝对路径""" if not images_dir or not os.path.isdir(images_dir): return content abs_images_dir = os.path.abspath(images_dir) - # 替换 Markdown 图片语法 def replace_img(match): alt = match.group(1) src = match.group(2) - if not src.startswith(('http://', 'https://', '/')): - # 相对路径,转为绝对路径 + if not src.startswith(('http://', 'https://', 'file://', '/')): abs_src = os.path.join(abs_images_dir, os.path.basename(src)) if os.path.exists(abs_src): return f'![{alt}](file://{abs_src})' @@ -146,118 +66,607 @@ def fix_image_paths(content, images_dir): return content -def create_combined_markdown(args, final_grade): - """创建合并后的 Markdown 文件""" +def markdown_to_html(md_content): + """将 Markdown 转换为 HTML(仅内容部分)""" + extensions = ['tables', 'fenced_code', 'nl2br'] + return markdown.markdown(md_content, extensions=extensions) + + +def generate_cover_page(student_id, assignment_name="VibeVault 期末大作业"): + """生成封面页 HTML""" + current_date = datetime.now().strftime('%Y年%m月%d日') + current_semester = "2025年秋季学期" - # 成绩摘要 - grade_summary = generate_grade_summary(final_grade) + return f''' +
+
+
课程大作业报告
+
+ +
+

《Java 程序设计》

+

期末大作业

+

{assignment_name}

+
+ +
+ + + + + + + + + + + + + + + + + +
学  号:{student_id or ' ' * 12}
姓  名:
班  级:
提交日期:{current_date}
+
+ + +
+ ''' + + +def generate_report_section(title, content, icon="📝"): + """生成报告章节 HTML""" + if not content or content.strip() in ['', '*(未提交)*']: + html_content = '

(未提交)

' + else: + html_content = markdown_to_html(content) - # 读取学生报告 + return f''' +
+

{icon} {title}

+
+ {html_content} +
+
+ ''' + + +def generate_grade_page(final_grade): + """生成评分详情页 HTML""" + total = final_grade.get("total_score", 0) + max_score = final_grade.get("max_score", 100) + breakdown = final_grade.get("breakdown", {}) + + # 编程测试详情 + prog = breakdown.get("programming", {}) + prog_rows = "" + if prog.get("groups"): + for group_name, group_info in prog["groups"].items(): + prog_rows += f''' + + {group_name} + {group_info.get('passed', 0)} / {group_info.get('total', 0)} + {group_info.get('score', 0):.1f} + {group_info.get('max_score', 0)} + + ''' + + # LLM 评分详情 + def format_llm_details(section_data, section_name): + criteria = section_data.get("criteria", []) + if not criteria: + return f'

无详细评分

' + + rows = "" + for c in criteria: + reason = c.get("reason", "").replace("<", "<").replace(">", ">") + rows += f''' + + {c.get('id', '')} + {c.get('score', 0)} + {reason} + + ''' + + confidence = section_data.get("confidence") + flags = section_data.get("flags", []) + footer = "" + if confidence: + footer += f'置信度: {confidence:.2f}' + if flags: + footer += f'标记: {", ".join(flags)}' + + return f''' + + + + + {rows} +
评分项得分评语
+ + ''' + + report = breakdown.get("report", {}) + frontend = breakdown.get("frontend", {}) + + return f''' +
+

📊 评分详情

+ +
+
+ {total:.1f} + / {max_score} +
+
总分
+
+ +
+

成绩汇总

+ + + + + + + + + + + + + + + + + + + + + + + + +
项目得分满分占比
编程测试{prog.get('score', 0):.1f}{prog.get('max_score', 80)}{prog.get('max_score', 80)}%
后端反思报告{report.get('score', 0):.1f}{report.get('max_score', 10)}{report.get('max_score', 10)}%
前端反思报告{frontend.get('score', 0):.1f}{frontend.get('max_score', 10)}{frontend.get('max_score', 10)}%
+
+ +
+

编程测试详情

+ + + + + {prog_rows or ''} +
测试组通过数得分满分
无测试数据
+
+ +
+

后端反思报告评分

+ {format_llm_details(report, 'report')} +
+ +
+

前端反思报告评分

+ {format_llm_details(frontend, 'frontend')} +
+ + +
+ ''' + + +def get_css_styles(): + """获取 PDF 样式""" + return ''' + @page { + size: A4; + margin: 2cm 2.5cm; + + @bottom-center { + content: counter(page); + font-size: 10pt; + color: #666; + } + } + + @page cover { + margin: 0; + @bottom-center { content: none; } + } + + @font-face { + font-family: 'Noto Sans CJK SC'; + src: local('Noto Sans CJK SC'), local('Noto Sans SC'), + local('Source Han Sans SC'), local('Source Han Sans CN'), + local('PingFang SC'), local('Microsoft YaHei'), + local('SimHei'), local('WenQuanYi Micro Hei'); + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: 'Noto Sans CJK SC', 'Source Han Sans SC', 'PingFang SC', + 'Microsoft YaHei', 'SimHei', 'WenQuanYi Micro Hei', sans-serif; + font-size: 11pt; + line-height: 1.8; + color: #333; + } + + /* 封面页样式 */ + .cover-page { + page: cover; + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + padding: 3cm; + page-break-after: always; + } + + .cover-header { + margin-bottom: 4cm; + } + + .university-name { + font-size: 18pt; + color: #1a5490; + letter-spacing: 0.5em; + font-weight: bold; + } + + .cover-title h1 { + font-size: 26pt; + color: #1a5490; + margin-bottom: 0.5cm; + font-weight: bold; + } + + .cover-title h2 { + font-size: 20pt; + color: #333; + margin-bottom: 0.3cm; + font-weight: normal; + } + + .cover-title h3 { + font-size: 14pt; + color: #666; + font-weight: normal; + } + + .cover-info { + margin-top: 3cm; + } + + .info-table { + margin: 0 auto; + border-collapse: collapse; + } + + .info-table td { + padding: 0.4cm 0.5cm; + font-size: 12pt; + } + + .info-table .label { + text-align: right; + color: #333; + } + + .info-table .value { + text-align: left; + min-width: 6cm; + } + + .info-table .underline { + border-bottom: 1px solid #333; + } + + .cover-footer { + margin-top: 4cm; + color: #666; + font-size: 11pt; + } + + /* 报告章节样式 */ + .report-section { + page-break-before: always; + } + + .section-title { + font-size: 18pt; + color: #1a5490; + border-bottom: 2px solid #1a5490; + padding-bottom: 0.3cm; + margin-bottom: 0.8cm; + } + + .section-content { + text-align: justify; + } + + .section-content h1 { + font-size: 16pt; + color: #1a5490; + margin: 1cm 0 0.5cm 0; + } + + .section-content h2 { + font-size: 14pt; + color: #333; + margin: 0.8cm 0 0.4cm 0; + } + + .section-content h3 { + font-size: 12pt; + color: #555; + margin: 0.6cm 0 0.3cm 0; + } + + .section-content p { + margin: 0.4cm 0; + text-indent: 2em; + } + + .section-content ul, .section-content ol { + margin: 0.4cm 0 0.4cm 1.5cm; + } + + .section-content li { + margin: 0.2cm 0; + } + + .section-content img { + max-width: 100%; + height: auto; + margin: 0.5cm auto; + display: block; + border: 1px solid #ddd; + } + + .section-content code { + font-family: 'Consolas', 'Monaco', monospace; + background: #f5f5f5; + padding: 0.1cm 0.2cm; + border-radius: 3px; + font-size: 10pt; + } + + .section-content pre { + background: #f5f5f5; + padding: 0.5cm; + border-radius: 5px; + overflow-x: auto; + font-size: 9pt; + margin: 0.5cm 0; + } + + .section-content blockquote { + border-left: 4px solid #1a5490; + padding-left: 0.5cm; + margin: 0.5cm 0; + color: #555; + background: #f9f9f9; + padding: 0.3cm 0.5cm; + } + + .section-content table { + width: 100%; + border-collapse: collapse; + margin: 0.5cm 0; + font-size: 10pt; + } + + .section-content th, .section-content td { + border: 1px solid #ddd; + padding: 0.3cm; + text-align: left; + } + + .section-content th { + background: #1a5490; + color: white; + } + + .section-content tr:nth-child(even) { + background: #f9f9f9; + } + + .empty-notice { + color: #999; + font-style: italic; + text-align: center; + padding: 2cm; + } + + /* 评分页样式 */ + .grade-page { + page-break-before: always; + } + + .page-title { + font-size: 18pt; + color: #1a5490; + text-align: center; + margin-bottom: 1cm; + } + + .total-score { + text-align: center; + margin: 1cm 0; + } + + .score-circle { + display: inline-block; + width: 4cm; + height: 4cm; + border: 4px solid #1a5490; + border-radius: 50%; + line-height: 4cm; + text-align: center; + } + + .score-value { + font-size: 28pt; + font-weight: bold; + color: #1a5490; + } + + .score-max { + font-size: 14pt; + color: #666; + } + + .score-label { + font-size: 12pt; + color: #666; + margin-top: 0.3cm; + } + + .grade-summary, .grade-details { + margin: 0.8cm 0; + } + + .grade-summary h2, .grade-details h2 { + font-size: 14pt; + color: #333; + border-bottom: 1px solid #ddd; + padding-bottom: 0.2cm; + margin-bottom: 0.4cm; + } + + .summary-table, .detail-table { + width: 100%; + border-collapse: collapse; + font-size: 10pt; + } + + .summary-table th, .summary-table td, + .detail-table th, .detail-table td { + border: 1px solid #ddd; + padding: 0.25cm 0.4cm; + text-align: left; + } + + .summary-table th, .detail-table th { + background: #1a5490; + color: white; + font-weight: normal; + } + + .summary-table tr:nth-child(even), + .detail-table tr:nth-child(even) { + background: #f9f9f9; + } + + .score-cell { + text-align: center; + font-weight: bold; + color: #1a5490; + } + + .reason-cell { + font-size: 9pt; + color: #555; + max-width: 10cm; + } + + .detail-footer { + font-size: 9pt; + color: #666; + margin-top: 0.2cm; + } + + .detail-footer .confidence { + margin-right: 1cm; + } + + .detail-footer .flags { + color: #c00; + } + + .no-detail { + color: #999; + font-style: italic; + padding: 0.5cm; + text-align: center; + } + + .grade-footer { + margin-top: 1cm; + padding-top: 0.5cm; + border-top: 1px solid #ddd; + font-size: 9pt; + color: #999; + text-align: center; + } + + .grade-footer p { + margin: 0.1cm 0; + text-indent: 0; + } + ''' + + +def create_full_html(args, final_grade, student_id): + """创建完整的 HTML 文档""" + + # 读取报告内容 report_content = read_file(args.report) frontend_content = read_file(args.frontend) # 修复图片路径 frontend_content = fix_image_paths(frontend_content, args.images) - # 合并内容 - combined = [ - grade_summary, - "# 📝 后端开发反思报告", - "", - report_content.strip() if report_content.strip() else "*(未提交)*", - "", - "---", - "", - "# 🎨 前端开发反思报告", - "", - frontend_content.strip() if frontend_content.strip() else "*(未提交)*", - ] + # 移除报告中的标题行(避免重复) + report_content = re.sub(r'^#\s*后端开发反思报告.*\n', '', report_content, flags=re.MULTILINE) + frontend_content = re.sub(r'^#\s*前端开发反思报告.*\n', '', frontend_content, flags=re.MULTILINE) - return "\n".join(combined) - - -def markdown_to_html(md_content): - """将 Markdown 转换为 HTML""" - extensions = ['tables', 'fenced_code', 'codehilite'] - html_body = markdown.markdown(md_content, extensions=extensions) - - # 添加 CSS 样式 + # 构建 HTML html = f''' - + - - + + Java程序设计 - 期末大作业报告 + -{html_body} + {generate_cover_page(student_id)} + {generate_report_section("后端开发反思报告", report_content)} + {generate_report_section("前端开发反思报告", frontend_content, "🎨")} + {generate_grade_page(final_grade)} ''' + return html -def convert_to_pdf(md_content, pdf_file, images_dir=None): - """使用 weasyprint 将 Markdown 转换为 PDF""" +def convert_to_pdf(html_content, pdf_file, images_dir=None): + """使用 weasyprint 生成 PDF""" if not HAS_PDF_SUPPORT: - print("weasyprint not available, skipping PDF generation", file=sys.stderr) + print("weasyprint not available", file=sys.stderr) return False try: - html_content = markdown_to_html(md_content) - - # 设置基础 URL 用于图片加载 + font_config = FontConfiguration() base_url = os.path.abspath(images_dir) if images_dir else os.getcwd() - HTML(string=html_content, base_url=base_url).write_pdf(pdf_file) + HTML(string=html_content, base_url=base_url).write_pdf( + pdf_file, + font_config=font_config + ) return True except Exception as e: print(f"PDF generation error: {e}", file=sys.stderr) @@ -265,37 +674,44 @@ def convert_to_pdf(md_content, pdf_file, images_dir=None): def main(): - parser = argparse.ArgumentParser(description="Generate PDF grade report") + parser = argparse.ArgumentParser(description="Generate professional PDF grade report") parser.add_argument("--report", default="REPORT.md", help="REPORT.md file path") parser.add_argument("--frontend", default="FRONTEND.md", help="FRONTEND.md file path") parser.add_argument("--grade", default="final_grade.json", help="Final grade JSON file") parser.add_argument("--images", default="images", help="Images directory") parser.add_argument("--out", default="grade_report.pdf", help="Output PDF file") - parser.add_argument("--student-id", default="", help="Student ID for filename") + parser.add_argument("--student-id", default="", help="Student ID") args = parser.parse_args() + # 从环境变量获取学生 ID + student_id = args.student_id or os.getenv("STUDENT_ID", "") + if not student_id: + repo = os.getenv("REPO", "") + match = re.search(r'-stu[_-]([a-zA-Z0-9_]+)$', repo) + if match: + student_id = match.group(1) + # 加载成绩 final_grade = load_json(args.grade, {"total_score": 0, "max_score": 100, "breakdown": {}}) - # 创建合并的 Markdown - combined_md = create_combined_markdown(args, final_grade) + # 创建 HTML + html_content = create_full_html(args, final_grade, student_id) - # 保存 Markdown 文件(始终保存,作为备份) - md_out = args.out.replace(".pdf", ".md") - with open(md_out, "w", encoding="utf-8") as f: - f.write(combined_md) - print(f"📝 Markdown report saved: {md_out}") + # 保存 HTML(调试用) + html_out = args.out.replace(".pdf", ".html") + with open(html_out, "w", encoding="utf-8") as f: + f.write(html_content) - # 尝试生成 PDF + # 生成 PDF if HAS_PDF_SUPPORT: - if convert_to_pdf(combined_md, args.out, args.images): + if convert_to_pdf(html_content, args.out, args.images): print(f"✅ PDF report generated: {args.out}") return 0 else: - print(f"⚠️ PDF generation failed, markdown saved as fallback", file=sys.stderr) - return 0 + print(f"⚠️ PDF generation failed", file=sys.stderr) + return 1 else: - print(f"ℹ️ PDF support not available (weasyprint not installed), markdown saved") + print(f"ℹ️ weasyprint not installed, HTML saved: {html_out}") return 0 diff --git a/.gitea/workflows/autograde.yml b/.gitea/workflows/autograde.yml index 36d6d1c..9cadbc5 100644 --- a/.gitea/workflows/autograde.yml +++ b/.gitea/workflows/autograde.yml @@ -32,8 +32,11 @@ jobs: done apt-get -o Acquire::Check-Valid-Until=false update -y DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip rsync \ - libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info + libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info \ + fonts-noto-cjk fonts-wqy-microhei pip3 install --break-system-packages python-dotenv requests markdown weasyprint -i https://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com + # 刷新字体缓存 + fc-cache -f -v > /dev/null 2>&1 || true rm -rf /var/lib/apt/lists/* - name: Configure Gradle mirror (Aliyun)