- Add generate_pdf_report.py using weasyprint - Install weasyprint dependencies in workflow - Generate combined report with grades, REPORT.md, FRONTEND.md - Upload PDF/Markdown report to student repo's reports/ directory
304 lines
8.9 KiB
Python
304 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
生成 PDF 成绩报告
|
||
|
||
合并 REPORT.md、FRONTEND.md、成绩摘要,生成完整的 PDF 报告
|
||
使用 weasyprint 进行 HTML→PDF 转换
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import markdown
|
||
from weasyprint import HTML, CSS
|
||
HAS_PDF_SUPPORT = True
|
||
except ImportError:
|
||
HAS_PDF_SUPPORT = False
|
||
|
||
|
||
def load_json(filepath, default=None):
|
||
"""安全加载 JSON 文件"""
|
||
if not os.path.exists(filepath):
|
||
return default or {}
|
||
try:
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
print(f"Error loading {filepath}: {e}", file=sys.stderr)
|
||
return default or {}
|
||
|
||
|
||
def read_file(filepath):
|
||
"""读取文件内容"""
|
||
if os.path.exists(filepath):
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
return f.read()
|
||
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://', '/')):
|
||
# 相对路径,转为绝对路径
|
||
abs_src = os.path.join(abs_images_dir, os.path.basename(src))
|
||
if os.path.exists(abs_src):
|
||
return f''
|
||
return match.group(0)
|
||
|
||
content = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', replace_img, content)
|
||
return content
|
||
|
||
|
||
def create_combined_markdown(args, final_grade):
|
||
"""创建合并后的 Markdown 文件"""
|
||
|
||
# 成绩摘要
|
||
grade_summary = generate_grade_summary(final_grade)
|
||
|
||
# 读取学生报告
|
||
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 "*(未提交)*",
|
||
]
|
||
|
||
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 = f'''<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<style>
|
||
body {{
|
||
font-family: "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||
font-size: 12pt;
|
||
line-height: 1.6;
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}}
|
||
h1 {{ color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
|
||
h2 {{ color: #34495e; }}
|
||
h3 {{ color: #7f8c8d; }}
|
||
table {{
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 15px 0;
|
||
}}
|
||
th, td {{
|
||
border: 1px solid #ddd;
|
||
padding: 8px;
|
||
text-align: left;
|
||
}}
|
||
th {{ background-color: #3498db; color: white; }}
|
||
tr:nth-child(even) {{ background-color: #f9f9f9; }}
|
||
code {{
|
||
background-color: #f4f4f4;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-family: "Consolas", monospace;
|
||
}}
|
||
pre {{
|
||
background-color: #f4f4f4;
|
||
padding: 15px;
|
||
border-radius: 5px;
|
||
overflow-x: auto;
|
||
}}
|
||
img {{
|
||
max-width: 100%;
|
||
height: auto;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
margin: 10px 0;
|
||
}}
|
||
hr {{ border: none; border-top: 1px solid #ddd; margin: 30px 0; }}
|
||
blockquote {{
|
||
border-left: 4px solid #3498db;
|
||
margin: 15px 0;
|
||
padding: 10px 20px;
|
||
background-color: #f9f9f9;
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{html_body}
|
||
</body>
|
||
</html>'''
|
||
return html
|
||
|
||
|
||
def convert_to_pdf(md_content, pdf_file, images_dir=None):
|
||
"""使用 weasyprint 将 Markdown 转换为 PDF"""
|
||
if not HAS_PDF_SUPPORT:
|
||
print("weasyprint not available, skipping PDF generation", file=sys.stderr)
|
||
return False
|
||
|
||
try:
|
||
html_content = markdown_to_html(md_content)
|
||
|
||
# 设置基础 URL 用于图片加载
|
||
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)
|
||
return True
|
||
except Exception as e:
|
||
print(f"PDF generation error: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Generate 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")
|
||
args = parser.parse_args()
|
||
|
||
# 加载成绩
|
||
final_grade = load_json(args.grade, {"total_score": 0, "max_score": 100, "breakdown": {}})
|
||
|
||
# 创建合并的 Markdown
|
||
combined_md = create_combined_markdown(args, final_grade)
|
||
|
||
# 保存 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}")
|
||
|
||
# 尝试生成 PDF
|
||
if HAS_PDF_SUPPORT:
|
||
if convert_to_pdf(combined_md, 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
|
||
else:
|
||
print(f"ℹ️ PDF support not available (weasyprint not installed), markdown saved")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|