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())
|