396 lines
15 KiB
Python
396 lines
15 KiB
Python
"""使用 reportlab 直接生成学生答卷 PDF,绕过 wkhtmltopdf 中文乱码问题。
|
||
|
||
Windows 环境的 wkhtmltopdf 0.12.6 (with patched qt) 无法正确加载 @font-face 或系统字体,
|
||
导致 PDF 中文渲染为乱码/方框。本模块使用 reportlab 在 Python 端直接绘制 PDF,
|
||
并注册 Windows 自带中文字体(SimHei / Microsoft YaHei),从根本上保证中文正常显示。
|
||
"""
|
||
|
||
import re
|
||
from datetime import datetime
|
||
from io import BytesIO
|
||
|
||
from reportlab.lib import colors
|
||
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||
from reportlab.lib.units import mm
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from reportlab.platypus import (
|
||
Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, KeepTogether,
|
||
PageBreak,
|
||
)
|
||
|
||
|
||
# 尝试注册系统中文字体,按优先级尝试多个路径
|
||
_FONT_PATHS = [
|
||
('SimHei', r'C:\Windows\Fonts\simhei.ttf'),
|
||
('MicrosoftYaHei', r'C:\Windows\Fonts\msyh.ttc'),
|
||
('SimSun', r'C:\Windows\Fonts\simsun.ttc'),
|
||
]
|
||
|
||
_REGISTERED_FONTS = {}
|
||
|
||
|
||
def _register_chinese_fonts():
|
||
"""注册系统中文字体;返回成功注册的字体名列表。"""
|
||
if _REGISTERED_FONTS:
|
||
return list(_REGISTERED_FONTS.keys())
|
||
for name, path in _FONT_PATHS:
|
||
try:
|
||
pdfmetrics.registerFont(TTFont(name, path))
|
||
_REGISTERED_FONTS[name] = path
|
||
except Exception:
|
||
pass
|
||
return list(_REGISTERED_FONTS.keys())
|
||
|
||
|
||
def _get_font_name(preferred='SimHei'):
|
||
"""返回可用的中文字体名,优先使用指定字体。"""
|
||
fonts = _register_chinese_fonts()
|
||
if preferred in fonts:
|
||
return preferred
|
||
return fonts[0] if fonts else 'Helvetica'
|
||
|
||
|
||
def _strip_html(html):
|
||
"""将 HTML 片段转为纯文本(保留换行/空格的可读性)。"""
|
||
if not html:
|
||
return ''
|
||
text = str(html)
|
||
text = re.sub(r'\s+', ' ', text)
|
||
text = re.sub(r'</(p|div|br|li|h[1-6]|tr)>', '\n', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'<li>', '• ', text, flags=re.IGNORECASE)
|
||
text = re.sub(r'<[^>]+>', ' ', text)
|
||
lines = [re.sub(r'\s+', ' ', line).strip() for line in text.split('\n')]
|
||
return '\n'.join(line for line in lines if line)
|
||
|
||
|
||
def _escape_xml(text):
|
||
"""为 reportlab Paragraph 转义 XML 特殊字符。"""
|
||
if not text:
|
||
return ''
|
||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||
|
||
|
||
def _p(text, style):
|
||
"""生成 Paragraph,并转义文本中的 XML 字符。"""
|
||
return Paragraph(_escape_xml(text), style)
|
||
|
||
|
||
def _build_styles():
|
||
"""构建 PDF 中使用的段落样式。"""
|
||
font_name = _get_font_name()
|
||
base = getSampleStyleSheet()
|
||
styles = {}
|
||
styles['title'] = ParagraphStyle(
|
||
'title', parent=base['Title'],
|
||
fontName=font_name, fontSize=18, leading=26,
|
||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||
spaceAfter=6,
|
||
)
|
||
styles['subtitle'] = ParagraphStyle(
|
||
'subtitle', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=16,
|
||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||
spaceAfter=12,
|
||
)
|
||
styles['score_big'] = ParagraphStyle(
|
||
'score_big', parent=base['Normal'],
|
||
fontName=font_name, fontSize=26, leading=32,
|
||
textColor=colors.HexColor('#2563eb'), alignment=TA_LEFT,
|
||
)
|
||
styles['score_label'] = ParagraphStyle(
|
||
'score_label', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=14,
|
||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||
)
|
||
styles['score_pass'] = ParagraphStyle(
|
||
'score_pass', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=14,
|
||
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
|
||
)
|
||
styles['score_fail'] = ParagraphStyle(
|
||
'score_fail', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=14,
|
||
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
|
||
)
|
||
styles['section_title'] = ParagraphStyle(
|
||
'section_title', parent=base['Normal'],
|
||
fontName=font_name, fontSize=12, leading=18,
|
||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||
spaceBefore=14, spaceAfter=8,
|
||
)
|
||
styles['question_title'] = ParagraphStyle(
|
||
'question_title', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=16,
|
||
textColor=colors.HexColor('#1f2937'), alignment=TA_LEFT,
|
||
spaceAfter=4,
|
||
)
|
||
styles['body'] = ParagraphStyle(
|
||
'body', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=16,
|
||
textColor=colors.HexColor('#374151'), alignment=TA_LEFT,
|
||
)
|
||
styles['body_green'] = ParagraphStyle(
|
||
'body_green', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=16,
|
||
textColor=colors.HexColor('#15803d'), alignment=TA_LEFT,
|
||
)
|
||
styles['body_red'] = ParagraphStyle(
|
||
'body_red', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=16,
|
||
textColor=colors.HexColor('#b91c1c'), alignment=TA_LEFT,
|
||
)
|
||
styles['body_gray'] = ParagraphStyle(
|
||
'body_gray', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=16,
|
||
textColor=colors.HexColor('#6b7280'), alignment=TA_LEFT,
|
||
)
|
||
styles['body_orange'] = ParagraphStyle(
|
||
'body_orange', parent=base['Normal'],
|
||
fontName=font_name, fontSize=10, leading=16,
|
||
textColor=colors.HexColor('#b45309'), alignment=TA_LEFT,
|
||
)
|
||
styles['tag'] = ParagraphStyle(
|
||
'tag', parent=base['Normal'],
|
||
fontName=font_name, fontSize=9, leading=12,
|
||
textColor=colors.HexColor('#3730a3'), alignment=TA_CENTER,
|
||
)
|
||
styles['tag_green'] = ParagraphStyle(
|
||
'tag_green', parent=base['Normal'],
|
||
fontName=font_name, fontSize=9, leading=12,
|
||
textColor=colors.HexColor('#15803d'), alignment=TA_CENTER,
|
||
)
|
||
styles['tag_red'] = ParagraphStyle(
|
||
'tag_red', parent=base['Normal'],
|
||
fontName=font_name, fontSize=9, leading=12,
|
||
textColor=colors.HexColor('#b91c1c'), alignment=TA_CENTER,
|
||
)
|
||
styles['footer'] = ParagraphStyle(
|
||
'footer', parent=base['Normal'],
|
||
fontName=font_name, fontSize=9, leading=12,
|
||
textColor=colors.HexColor('#9ca3af'), alignment=TA_RIGHT,
|
||
)
|
||
return styles
|
||
|
||
|
||
def _question_type_label(qtype):
|
||
return {
|
||
'single_choice': '单选题',
|
||
'multiple_choice': '多选题',
|
||
'true_false': '判断题',
|
||
'fill_blank': '填空题',
|
||
'essay': '简答题',
|
||
}.get(qtype, qtype)
|
||
|
||
|
||
def _build_answer_block(ans, styles, question_number=None):
|
||
"""为一道题生成 reportlab 元素列表。
|
||
|
||
:param question_number: 在试卷中显示的题号;默认使用题目本身的 sequence。
|
||
"""
|
||
story = []
|
||
q = ans.question_id
|
||
|
||
# 题头:题号 + 题型 + 分数 + 对错
|
||
score_text = f'{ans.score or 0} / {ans.max_score or 0} 分'
|
||
status_style = styles['tag_green'] if ans.is_correct else styles['tag_red']
|
||
status_text = ''
|
||
if ans.question_type != 'essay':
|
||
status_text = '正确' if ans.is_correct else '错误'
|
||
|
||
display_number = question_number if question_number is not None else (ans.sequence or 0)
|
||
header_data = [
|
||
[
|
||
_p(f'第 {display_number} 题', styles['question_title']),
|
||
_p(_question_type_label(ans.question_type), styles['tag']),
|
||
_p(score_text, styles['body']),
|
||
],
|
||
]
|
||
if status_text:
|
||
header_data[0].append(_p(status_text, status_style))
|
||
|
||
col_count = len(header_data[0])
|
||
if col_count == 3:
|
||
widths = [None, 35 * mm, 35 * mm]
|
||
else:
|
||
widths = [None, 35 * mm, 35 * mm, 25 * mm]
|
||
|
||
header_table = Table(header_data, colWidths=widths)
|
||
header_table.setStyle(TableStyle([
|
||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||
]))
|
||
story.append(header_table)
|
||
|
||
# 题干
|
||
story.append(_p(_strip_html(ans.question_text), styles['body']))
|
||
|
||
# 选择题选项
|
||
if ans.question_type in ('single_choice', 'multiple_choice') and q.option_ids:
|
||
for opt in q.option_ids.sorted(lambda o: (o.sequence, o.id)):
|
||
badges = []
|
||
if opt.is_correct:
|
||
badges.append('正确')
|
||
if opt in ans.selected_option_ids:
|
||
badges.append('你的选择')
|
||
badge_text = f' [{" · ".join(badges)}]' if badges else ''
|
||
option_text = f'{opt.label}. {_strip_html(opt.content)}{badge_text}'
|
||
story.append(_p(option_text, styles['body']))
|
||
|
||
# 学生作答
|
||
if ans.question_type == 'essay':
|
||
answer_text = _strip_html(ans.answer_text) or '未作答'
|
||
else:
|
||
answer_text = ans.answer_display or '未作答'
|
||
story.append(_p(f'学生作答:{answer_text}', styles['body']))
|
||
|
||
# 正确答案 / 参考答案
|
||
if ans.question_type == 'single_choice':
|
||
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
|
||
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
|
||
elif ans.question_type == 'multiple_choice':
|
||
correct_labels = ', '.join(opt.label for opt in q._get_correct_options())
|
||
story.append(_p(f'正确答案:{correct_labels}', styles['body_green']))
|
||
elif ans.question_type == 'true_false':
|
||
text = '正确' if q.correct_answer == 'True' else '错误'
|
||
story.append(_p(f'正确答案:{text}', styles['body_green']))
|
||
elif ans.question_type == 'fill_blank':
|
||
story.append(_p(f'正确答案:{q.correct_answer or "-"}', styles['body_green']))
|
||
elif ans.question_type == 'essay':
|
||
ref = _strip_html(q.reference_answer) or '-'
|
||
story.append(_p(f'参考答案:{ref}', styles['body_gray']))
|
||
|
||
# 本题评语
|
||
if ans.teacher_comment:
|
||
story.append(_p(f'评语:{ans.teacher_comment}', styles['body_orange']))
|
||
|
||
return story
|
||
|
||
|
||
def _build_paper_story(paper, styles):
|
||
"""构建单份答卷的 PDF 流元素列表(不含 doc.build)。"""
|
||
story = []
|
||
|
||
# 表头:考试名 + 学员信息
|
||
story.append(_p(paper.exam_id.name or '考试', styles['title']))
|
||
|
||
info_parts = [f"学员:{paper.student_id.name or '-'}"]
|
||
if paper.course_id:
|
||
info_parts.append(f"课程:{paper.course_id.name}")
|
||
if paper.chapter_ids:
|
||
info_parts.append(f"章节:{', '.join(paper.chapter_ids.mapped('name'))}")
|
||
state_map = dict(paper._fields['state'].selection)
|
||
info_parts.append(f"状态:{state_map.get(paper.state, paper.state)}")
|
||
if paper.submit_time:
|
||
info_parts.append(f"交卷:{paper.submit_time.strftime('%Y-%m-%d %H:%M')}")
|
||
story.append(_p(' | '.join(info_parts), styles['subtitle']))
|
||
|
||
# 得分概览(仅显示分数数字 + 及格状态 + 及格线)
|
||
pass_style = styles['score_pass'] if paper.is_passed else styles['score_fail']
|
||
pass_text = '及格' if paper.is_passed else '不及格'
|
||
score_row = [
|
||
_p(f"{paper.score or 0} 分", styles['score_big']),
|
||
_p(pass_text, pass_style),
|
||
_p(f"及格分:{paper.pass_score or 0} 分", styles['score_label']),
|
||
]
|
||
score_table = Table([score_row], colWidths=[70 * mm, 35 * mm, 70 * mm])
|
||
score_table.setStyle(TableStyle([
|
||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8fafc')),
|
||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 12),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 12),
|
||
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
|
||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||
]))
|
||
story.append(score_table)
|
||
story.append(Spacer(1, 8 * mm))
|
||
|
||
# 答题详情
|
||
story.append(_p('答题详情', styles['section_title']))
|
||
answers = paper.answer_ids.sorted(lambda a: (a.sequence, a.id))
|
||
for idx, ans in enumerate(answers):
|
||
block = _build_answer_block(ans, styles, question_number=idx + 1)
|
||
block_table = Table([[element] for element in block], colWidths=[170 * mm])
|
||
block_table.setStyle(TableStyle([
|
||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e5e7eb')),
|
||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ffffff')),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||
]))
|
||
story.append(KeepTogether(block_table))
|
||
story.append(Spacer(1, 4 * mm))
|
||
|
||
# 整卷评语
|
||
if paper.teacher_comment:
|
||
story.append(Spacer(1, 6 * mm))
|
||
comment_table = Table(
|
||
[[_p(f"整卷评语:{paper.teacher_comment}", styles['body_orange'])]],
|
||
colWidths=[170 * mm]
|
||
)
|
||
comment_table.setStyle(TableStyle([
|
||
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#fffbeb')),
|
||
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#fde68a')),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||
]))
|
||
story.append(comment_table)
|
||
|
||
# 页脚生成时间
|
||
story.append(Spacer(1, 10 * mm))
|
||
story.append(_p(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['footer']))
|
||
|
||
return story
|
||
|
||
|
||
def render_paper_pdf(paper):
|
||
"""渲染指定答卷的 PDF 字节流。"""
|
||
_register_chinese_fonts()
|
||
styles = _build_styles()
|
||
|
||
buffer = BytesIO()
|
||
doc = SimpleDocTemplate(
|
||
buffer, pagesize=A4,
|
||
rightMargin=20 * mm, leftMargin=20 * mm,
|
||
topMargin=20 * mm, bottomMargin=18 * mm,
|
||
)
|
||
|
||
story = _build_paper_story(paper, styles)
|
||
doc.build(story)
|
||
pdf_bytes = buffer.getvalue()
|
||
buffer.close()
|
||
return pdf_bytes
|
||
|
||
|
||
def render_papers_pdf(papers):
|
||
"""将多份答卷合并渲染为一个 PDF 字节流(每份之间自动分页)。"""
|
||
_register_chinese_fonts()
|
||
styles = _build_styles()
|
||
|
||
buffer = BytesIO()
|
||
doc = SimpleDocTemplate(
|
||
buffer, pagesize=A4,
|
||
rightMargin=20 * mm, leftMargin=20 * mm,
|
||
topMargin=20 * mm, bottomMargin=18 * mm,
|
||
)
|
||
|
||
story = []
|
||
papers_list = list(papers)
|
||
for idx, paper in enumerate(papers_list):
|
||
story.extend(_build_paper_story(paper, styles))
|
||
if idx < len(papers_list) - 1:
|
||
story.append(PageBreak())
|
||
|
||
doc.build(story)
|
||
pdf_bytes = buffer.getvalue()
|
||
buffer.close()
|
||
return pdf_bytes
|