feat: add student info and watermark to PDF report

- Auto-fill student name/class from .student_info.json
- Add anti-cheating watermark with student ID and unique hash
- Pass commit SHA for watermark generation
- Read student info from workflow environment
This commit is contained in:
sit002 2025-12-02 13:55:29 +08:00
parent 6629215d11
commit fd73c62d85
2 changed files with 240 additions and 134 deletions

View File

@ -7,9 +7,11 @@
- 后端开发反思报告 - 后端开发反思报告
- 前端开发反思报告 - 前端开发反思报告
- 评分详情页 - 评分详情页
- 防伪水印
""" """
import argparse import argparse
import hashlib
import json import json
import os import os
import re import re
@ -72,11 +74,23 @@ def markdown_to_html(md_content):
return markdown.markdown(md_content, extensions=extensions) return markdown.markdown(md_content, extensions=extensions)
def generate_cover_page(student_id, assignment_name="VibeVault 期末大作业"): def generate_watermark_id(student_id, commit_sha):
"""生成唯一的水印标识"""
raw = f"{student_id}-{commit_sha}-{datetime.now().isoformat()}"
return hashlib.sha256(raw.encode()).hexdigest()[:16].upper()
def generate_cover_page(student_id, student_name="", class_name="",
assignment_name="VibeVault 期末大作业"):
"""生成封面页 HTML""" """生成封面页 HTML"""
current_date = datetime.now().strftime('%Y年%m月%d') current_date = datetime.now().strftime('%Y年%m月%d')
current_semester = "2025年秋季学期" current_semester = "2025年秋季学期"
# 如果有学生姓名,直接显示;否则留空供手写
name_value = student_name if student_name else ' ' * 8
class_value = class_name if class_name else ' ' * 8
id_value = student_id if student_id else ' ' * 8
return f''' return f'''
<div class="cover-page"> <div class="cover-page">
<div class="cover-header"> <div class="cover-header">
@ -93,15 +107,15 @@ def generate_cover_page(student_id, assignment_name="VibeVault 期末大作业")
<table class="info-table"> <table class="info-table">
<tr> <tr>
<td class="label">&emsp;&emsp;</td> <td class="label">&emsp;&emsp;</td>
<td class="value underline">{student_id or '&emsp;' * 12}</td> <td class="value underline">{id_value}</td>
</tr> </tr>
<tr> <tr>
<td class="label">&emsp;&emsp;</td> <td class="label">&emsp;&emsp;</td>
<td class="value underline">&emsp;</td> <td class="value underline">{name_value}</td>
</tr> </tr>
<tr> <tr>
<td class="label">&emsp;&emsp;</td> <td class="label">&emsp;&emsp;</td>
<td class="value underline">&emsp;</td> <td class="value underline">{class_value}</td>
</tr> </tr>
<tr> <tr>
<td class="label">提交日期</td> <td class="label">提交日期</td>
@ -155,7 +169,7 @@ def generate_grade_page(final_grade):
''' '''
# LLM 评分详情 # LLM 评分详情
def format_llm_details(section_data, section_name): def format_llm_details(section_data):
criteria = section_data.get("criteria", []) criteria = section_data.get("criteria", [])
if not criteria: if not criteria:
return f'<p class="no-detail">无详细评分</p>' return f'<p class="no-detail">无详细评分</p>'
@ -245,12 +259,12 @@ def generate_grade_page(final_grade):
<div class="grade-details"> <div class="grade-details">
<h2>后端反思报告评分</h2> <h2>后端反思报告评分</h2>
{format_llm_details(report, 'report')} {format_llm_details(report)}
</div> </div>
<div class="grade-details"> <div class="grade-details">
<h2>前端反思报告评分</h2> <h2>前端反思报告评分</h2>
{format_llm_details(frontend, 'frontend')} {format_llm_details(frontend)}
</div> </div>
<div class="grade-footer"> <div class="grade-footer">
@ -261,49 +275,85 @@ def generate_grade_page(final_grade):
''' '''
def get_css_styles(): def get_css_styles(watermark_text=""):
"""获取 PDF 样式""" """获取 PDF 样式,包含水印"""
return '''
@page { # 水印样式
watermark_css = ""
if watermark_text:
watermark_css = f'''
/* 水印 */
body::after {{
content: "{watermark_text}";
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 60pt;
color: rgba(200, 200, 200, 0.15);
white-space: nowrap;
pointer-events: none;
z-index: 9999;
}}
.report-section::before,
.grade-page::before {{
content: "{watermark_text}";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 48pt;
color: rgba(200, 200, 200, 0.12);
white-space: nowrap;
pointer-events: none;
z-index: -1;
}}
'''
return f'''
@page {{
size: A4; size: A4;
margin: 2cm 2.5cm; margin: 2cm 2.5cm;
@bottom-center { @bottom-center {{
content: counter(page); content: counter(page);
font-size: 10pt; font-size: 10pt;
color: #666; color: #666;
} }}
} }}
@page cover { @page cover {{
margin: 0; margin: 0;
@bottom-center { content: none; } @bottom-center {{ content: none; }}
} }}
@font-face { @font-face {{
font-family: 'Noto Sans CJK SC'; font-family: 'Noto Sans CJK SC';
src: local('Noto Sans CJK SC'), local('Noto Sans SC'), src: local('Noto Sans CJK SC'), local('Noto Sans SC'),
local('Source Han Sans SC'), local('Source Han Sans CN'), local('Source Han Sans SC'), local('Source Han Sans CN'),
local('PingFang SC'), local('Microsoft YaHei'), local('PingFang SC'), local('Microsoft YaHei'),
local('SimHei'), local('WenQuanYi Micro Hei'); local('SimHei'), local('WenQuanYi Micro Hei');
} }}
* { * {{
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }}
body { body {{
font-family: 'Noto Sans CJK SC', 'Source Han Sans SC', 'PingFang SC', font-family: 'Noto Sans CJK SC', 'Source Han Sans SC', 'PingFang SC',
'Microsoft YaHei', 'SimHei', 'WenQuanYi Micro Hei', sans-serif; 'Microsoft YaHei', 'SimHei', 'WenQuanYi Micro Hei', sans-serif;
font-size: 11pt; font-size: 11pt;
line-height: 1.8; line-height: 1.8;
color: #333; color: #333;
} }}
{watermark_css}
/* 封面页样式 */ /* 封面页样式 */
.cover-page { .cover-page {{
page: cover; page: cover;
height: 100vh; height: 100vh;
display: flex; display: flex;
@ -313,202 +363,204 @@ def get_css_styles():
text-align: center; text-align: center;
padding: 3cm; padding: 3cm;
page-break-after: always; page-break-after: always;
} }}
.cover-header { .cover-header {{
margin-bottom: 4cm; margin-bottom: 4cm;
} }}
.university-name { .university-name {{
font-size: 18pt; font-size: 18pt;
color: #1a5490; color: #1a5490;
letter-spacing: 0.5em; letter-spacing: 0.5em;
font-weight: bold; font-weight: bold;
} }}
.cover-title h1 { .cover-title h1 {{
font-size: 26pt; font-size: 26pt;
color: #1a5490; color: #1a5490;
margin-bottom: 0.5cm; margin-bottom: 0.5cm;
font-weight: bold; font-weight: bold;
} }}
.cover-title h2 { .cover-title h2 {{
font-size: 20pt; font-size: 20pt;
color: #333; color: #333;
margin-bottom: 0.3cm; margin-bottom: 0.3cm;
font-weight: normal; font-weight: normal;
} }}
.cover-title h3 { .cover-title h3 {{
font-size: 14pt; font-size: 14pt;
color: #666; color: #666;
font-weight: normal; font-weight: normal;
} }}
.cover-info { .cover-info {{
margin-top: 3cm; margin-top: 3cm;
} }}
.info-table { .info-table {{
margin: 0 auto; margin: 0 auto;
border-collapse: collapse; border-collapse: collapse;
} }}
.info-table td { .info-table td {{
padding: 0.4cm 0.5cm; padding: 0.4cm 0.5cm;
font-size: 12pt; font-size: 12pt;
} }}
.info-table .label { .info-table .label {{
text-align: right; text-align: right;
color: #333; color: #333;
} }}
.info-table .value { .info-table .value {{
text-align: left; text-align: left;
min-width: 6cm; min-width: 6cm;
} }}
.info-table .underline { .info-table .underline {{
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
} }}
.cover-footer { .cover-footer {{
margin-top: 4cm; margin-top: 4cm;
color: #666; color: #666;
font-size: 11pt; font-size: 11pt;
} }}
/* 报告章节样式 */ /* 报告章节样式 */
.report-section { .report-section {{
page-break-before: always; page-break-before: always;
} position: relative;
}}
.section-title { .section-title {{
font-size: 18pt; font-size: 18pt;
color: #1a5490; color: #1a5490;
border-bottom: 2px solid #1a5490; border-bottom: 2px solid #1a5490;
padding-bottom: 0.3cm; padding-bottom: 0.3cm;
margin-bottom: 0.8cm; margin-bottom: 0.8cm;
} }}
.section-content { .section-content {{
text-align: justify; text-align: justify;
} }}
.section-content h1 { .section-content h1 {{
font-size: 16pt; font-size: 16pt;
color: #1a5490; color: #1a5490;
margin: 1cm 0 0.5cm 0; margin: 1cm 0 0.5cm 0;
} }}
.section-content h2 { .section-content h2 {{
font-size: 14pt; font-size: 14pt;
color: #333; color: #333;
margin: 0.8cm 0 0.4cm 0; margin: 0.8cm 0 0.4cm 0;
} }}
.section-content h3 { .section-content h3 {{
font-size: 12pt; font-size: 12pt;
color: #555; color: #555;
margin: 0.6cm 0 0.3cm 0; margin: 0.6cm 0 0.3cm 0;
} }}
.section-content p { .section-content p {{
margin: 0.4cm 0; margin: 0.4cm 0;
text-indent: 2em; text-indent: 2em;
} }}
.section-content ul, .section-content ol { .section-content ul, .section-content ol {{
margin: 0.4cm 0 0.4cm 1.5cm; margin: 0.4cm 0 0.4cm 1.5cm;
} }}
.section-content li { .section-content li {{
margin: 0.2cm 0; margin: 0.2cm 0;
} }}
.section-content img { .section-content img {{
max-width: 100%; max-width: 100%;
height: auto; height: auto;
margin: 0.5cm auto; margin: 0.5cm auto;
display: block; display: block;
border: 1px solid #ddd; border: 1px solid #ddd;
} }}
.section-content code { .section-content code {{
font-family: 'Consolas', 'Monaco', monospace; font-family: 'Consolas', 'Monaco', monospace;
background: #f5f5f5; background: #f5f5f5;
padding: 0.1cm 0.2cm; padding: 0.1cm 0.2cm;
border-radius: 3px; border-radius: 3px;
font-size: 10pt; font-size: 10pt;
} }}
.section-content pre { .section-content pre {{
background: #f5f5f5; background: #f5f5f5;
padding: 0.5cm; padding: 0.5cm;
border-radius: 5px; border-radius: 5px;
overflow-x: auto; overflow-x: auto;
font-size: 9pt; font-size: 9pt;
margin: 0.5cm 0; margin: 0.5cm 0;
} }}
.section-content blockquote { .section-content blockquote {{
border-left: 4px solid #1a5490; border-left: 4px solid #1a5490;
padding-left: 0.5cm; padding-left: 0.5cm;
margin: 0.5cm 0; margin: 0.5cm 0;
color: #555; color: #555;
background: #f9f9f9; background: #f9f9f9;
padding: 0.3cm 0.5cm; padding: 0.3cm 0.5cm;
} }}
.section-content table { .section-content table {{
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 0.5cm 0; margin: 0.5cm 0;
font-size: 10pt; font-size: 10pt;
} }}
.section-content th, .section-content td { .section-content th, .section-content td {{
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 0.3cm; padding: 0.3cm;
text-align: left; text-align: left;
} }}
.section-content th { .section-content th {{
background: #1a5490; background: #1a5490;
color: white; color: white;
} }}
.section-content tr:nth-child(even) { .section-content tr:nth-child(even) {{
background: #f9f9f9; background: #f9f9f9;
} }}
.empty-notice { .empty-notice {{
color: #999; color: #999;
font-style: italic; font-style: italic;
text-align: center; text-align: center;
padding: 2cm; padding: 2cm;
} }}
/* 评分页样式 */ /* 评分页样式 */
.grade-page { .grade-page {{
page-break-before: always; page-break-before: always;
} position: relative;
}}
.page-title { .page-title {{
font-size: 18pt; font-size: 18pt;
color: #1a5490; color: #1a5490;
text-align: center; text-align: center;
margin-bottom: 1cm; margin-bottom: 1cm;
} }}
.total-score { .total-score {{
text-align: center; text-align: center;
margin: 1cm 0; margin: 1cm 0;
} }}
.score-circle { .score-circle {{
display: inline-block; display: inline-block;
width: 4cm; width: 4cm;
height: 4cm; height: 4cm;
@ -516,111 +568,111 @@ def get_css_styles():
border-radius: 50%; border-radius: 50%;
line-height: 4cm; line-height: 4cm;
text-align: center; text-align: center;
} }}
.score-value { .score-value {{
font-size: 28pt; font-size: 28pt;
font-weight: bold; font-weight: bold;
color: #1a5490; color: #1a5490;
} }}
.score-max { .score-max {{
font-size: 14pt; font-size: 14pt;
color: #666; color: #666;
} }}
.score-label { .score-label {{
font-size: 12pt; font-size: 12pt;
color: #666; color: #666;
margin-top: 0.3cm; margin-top: 0.3cm;
} }}
.grade-summary, .grade-details { .grade-summary, .grade-details {{
margin: 0.8cm 0; margin: 0.8cm 0;
} }}
.grade-summary h2, .grade-details h2 { .grade-summary h2, .grade-details h2 {{
font-size: 14pt; font-size: 14pt;
color: #333; color: #333;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
padding-bottom: 0.2cm; padding-bottom: 0.2cm;
margin-bottom: 0.4cm; margin-bottom: 0.4cm;
} }}
.summary-table, .detail-table { .summary-table, .detail-table {{
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 10pt; font-size: 10pt;
} }}
.summary-table th, .summary-table td, .summary-table th, .summary-table td,
.detail-table th, .detail-table td { .detail-table th, .detail-table td {{
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 0.25cm 0.4cm; padding: 0.25cm 0.4cm;
text-align: left; text-align: left;
} }}
.summary-table th, .detail-table th { .summary-table th, .detail-table th {{
background: #1a5490; background: #1a5490;
color: white; color: white;
font-weight: normal; font-weight: normal;
} }}
.summary-table tr:nth-child(even), .summary-table tr:nth-child(even),
.detail-table tr:nth-child(even) { .detail-table tr:nth-child(even) {{
background: #f9f9f9; background: #f9f9f9;
} }}
.score-cell { .score-cell {{
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
color: #1a5490; color: #1a5490;
} }}
.reason-cell { .reason-cell {{
font-size: 9pt; font-size: 9pt;
color: #555; color: #555;
max-width: 10cm; max-width: 10cm;
} }}
.detail-footer { .detail-footer {{
font-size: 9pt; font-size: 9pt;
color: #666; color: #666;
margin-top: 0.2cm; margin-top: 0.2cm;
} }}
.detail-footer .confidence { .detail-footer .confidence {{
margin-right: 1cm; margin-right: 1cm;
} }}
.detail-footer .flags { .detail-footer .flags {{
color: #c00; color: #c00;
} }}
.no-detail { .no-detail {{
color: #999; color: #999;
font-style: italic; font-style: italic;
padding: 0.5cm; padding: 0.5cm;
text-align: center; text-align: center;
} }}
.grade-footer { .grade-footer {{
margin-top: 1cm; margin-top: 1cm;
padding-top: 0.5cm; padding-top: 0.5cm;
border-top: 1px solid #ddd; border-top: 1px solid #ddd;
font-size: 9pt; font-size: 9pt;
color: #999; color: #999;
text-align: center; text-align: center;
} }}
.grade-footer p { .grade-footer p {{
margin: 0.1cm 0; margin: 0.1cm 0;
text-indent: 0; text-indent: 0;
} }}
''' '''
def create_full_html(args, final_grade, student_id): def create_full_html(args, final_grade, student_info):
"""创建完整的 HTML 文档""" """创建完整的 HTML 文档"""
# 读取报告内容 # 读取报告内容
@ -634,16 +686,28 @@ def create_full_html(args, final_grade, student_id):
report_content = re.sub(r'^#\s*后端开发反思报告.*\n', '', report_content, flags=re.MULTILINE) report_content = re.sub(r'^#\s*后端开发反思报告.*\n', '', report_content, flags=re.MULTILINE)
frontend_content = re.sub(r'^#\s*前端开发反思报告.*\n', '', frontend_content, flags=re.MULTILINE) frontend_content = re.sub(r'^#\s*前端开发反思报告.*\n', '', frontend_content, flags=re.MULTILINE)
# 提取学生信息
student_id = student_info.get("student_id", "")
student_name = student_info.get("name", "")
class_name = student_info.get("class_name", "")
commit_sha = student_info.get("commit_sha", "")
# 生成水印文本
watermark_text = ""
if student_id:
watermark_id = generate_watermark_id(student_id, commit_sha)
watermark_text = f"{student_id} · {watermark_id}"
# 构建 HTML # 构建 HTML
html = f'''<!DOCTYPE html> html = f'''<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Java程序设计 - 期末大作业报告</title> <title>Java程序设计 - 期末大作业报告</title>
<style>{get_css_styles()}</style> <style>{get_css_styles(watermark_text)}</style>
</head> </head>
<body> <body>
{generate_cover_page(student_id)} {generate_cover_page(student_id, student_name, class_name)}
{generate_report_section("后端开发反思报告", report_content)} {generate_report_section("后端开发反思报告", report_content)}
{generate_report_section("前端开发反思报告", frontend_content, "🎨")} {generate_report_section("前端开发反思报告", frontend_content, "🎨")}
{generate_grade_page(final_grade)} {generate_grade_page(final_grade)}
@ -681,21 +745,40 @@ def main():
parser.add_argument("--images", default="images", help="Images directory") parser.add_argument("--images", default="images", help="Images directory")
parser.add_argument("--out", default="grade_report.pdf", help="Output PDF file") parser.add_argument("--out", default="grade_report.pdf", help="Output PDF file")
parser.add_argument("--student-id", default="", help="Student ID") parser.add_argument("--student-id", default="", help="Student ID")
parser.add_argument("--student-name", default="", help="Student name")
parser.add_argument("--class-name", default="", help="Class name")
parser.add_argument("--commit-sha", default="", help="Commit SHA for watermark")
args = parser.parse_args() args = parser.parse_args()
# 从环境变量获取学生 ID # 从环境变量获取学生信息
student_id = args.student_id or os.getenv("STUDENT_ID", "") student_id = args.student_id or os.getenv("STUDENT_ID", "")
student_name = args.student_name or os.getenv("STUDENT_NAME", "")
class_name = args.class_name or os.getenv("CLASS_NAME", "")
commit_sha = args.commit_sha or os.getenv("COMMIT_SHA", "")
# 从仓库名提取学生 ID
if not student_id: if not student_id:
repo = os.getenv("REPO", "") repo = os.getenv("REPO", "")
match = re.search(r'-stu[_-]([a-zA-Z0-9_]+)$', repo) match = re.search(r'-stu[_-]?st?(\d+)$', repo)
if match: if match:
student_id = match.group(1) student_id = match.group(1)
else:
match = re.search(r'-stu[_-]([a-zA-Z0-9_]+)$', repo)
if match:
student_id = match.group(1)
student_info = {
"student_id": student_id,
"name": student_name,
"class_name": class_name,
"commit_sha": commit_sha
}
# 加载成绩 # 加载成绩
final_grade = load_json(args.grade, {"total_score": 0, "max_score": 100, "breakdown": {}}) final_grade = load_json(args.grade, {"total_score": 0, "max_score": 100, "breakdown": {}})
# 创建 HTML # 创建 HTML
html_content = create_full_html(args, final_grade, student_id) html_content = create_full_html(args, final_grade, student_info)
# 保存 HTML调试用 # 保存 HTML调试用
html_out = args.out.replace(".pdf", ".html") html_out = args.out.replace(".pdf", ".html")

View File

@ -205,14 +205,37 @@ jobs:
- name: Generate PDF report - name: Generate PDF report
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
env:
REPO: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
run: | run: |
if [ -f final_grade.json ]; then if [ -f final_grade.json ]; then
# 读取学生信息文件(如果存在)
STUDENT_ID=""
STUDENT_NAME=""
CLASS_NAME=""
if [ -f .student_info.json ]; then
STUDENT_ID=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('student_id',''))" 2>/dev/null || echo "")
STUDENT_NAME=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('name',''))" 2>/dev/null || echo "")
CLASS_NAME=$(python3 -c "import json; d=json.load(open('.student_info.json')); print(d.get('class_name',''))" 2>/dev/null || echo "")
fi
# 如果没有学生信息文件,从仓库名提取学号
if [ -z "$STUDENT_ID" ]; then
STUDENT_ID=$(echo "$REPO" | sed -n 's/.*-stu[_-]\?\(st\)\?\([0-9]*\)$/\2/p')
fi
python3 ./.autograde/generate_pdf_report.py \ python3 ./.autograde/generate_pdf_report.py \
--report REPORT.md \ --report REPORT.md \
--frontend FRONTEND.md \ --frontend FRONTEND.md \
--grade final_grade.json \ --grade final_grade.json \
--images images \ --images images \
--out grade_report.pdf --out grade_report.pdf \
--student-id "$STUDENT_ID" \
--student-name "$STUDENT_NAME" \
--class-name "$CLASS_NAME" \
--commit-sha "$COMMIT_SHA"
fi fi
- name: Upload report to student repo - name: Upload report to student repo