Files
shcool/college/controllers/paper_pdf_reportlab.py
2026-08-27 19:53:56 +08:00

396 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""使用 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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