diff --git a/backend/app/models/mistake.py b/backend/app/models/mistake.py
index 0e3f524..569ad73 100644
--- a/backend/app/models/mistake.py
+++ b/backend/app/models/mistake.py
@@ -33,6 +33,7 @@ class Mistake(Base):
grade_level: Mapped[str] = mapped_column(String(20), nullable=True)
question_text: Mapped[str] = mapped_column(Text, nullable=False)
+ question_html: Mapped[str] = mapped_column(Text, nullable=True) # AI-generated formatted HTML
correct_answer: Mapped[str] = mapped_column(Text, nullable=True)
student_answer: Mapped[str] = mapped_column(Text, nullable=True)
error_analysis: Mapped[str] = mapped_column(Text, nullable=True)
diff --git a/backend/app/routers/exam.py b/backend/app/routers/exam.py
index 4e3a037..28dbf15 100644
--- a/backend/app/routers/exam.py
+++ b/backend/app/routers/exam.py
@@ -15,6 +15,7 @@ from app.models.mistake import Mistake
from app.models.user import User
from app.routers.dependencies import get_current_user
from app.schemas.exam import ExamGenerateRequest, ExamGenerateResponse
+from app.services.html_pdf_service import generate_html_exam_pdf, HAS_HTML2PDF
from app.services.pdf_service import generate_exam_pdf
router = APIRouter(prefix="/exam", tags=["试卷"])
@@ -43,10 +44,11 @@ async def generate_exam(
if not mistakes:
raise HTTPException(status_code=400, detail="没有符合条件的错题")
- # Prepare mistake dicts for PDF (include image paths)
+ # Prepare mistake dicts for PDF (include image paths + AI HTML)
mistake_dicts = [
{
"question_text": m.question_text,
+ "question_html": m.question_html,
"question_type": m.question_type,
"correct_answer": m.correct_answer,
"student_answer": m.student_answer,
@@ -60,20 +62,30 @@ async def generate_exam(
for m in mistakes
]
- # Generate PDF
+ # Generate PDF — prefer HTML-based (weasyprint) for rich formatting
filename = f"exam_{uuid.uuid4().hex[:12]}.pdf"
user_dir = os.path.join(settings.UPLOAD_DIR, str(user.id))
os.makedirs(user_dir, exist_ok=True)
output_path = os.path.join(user_dir, filename)
- generate_exam_pdf(
- title=data.title,
- student_name=user.display_name or user.username,
- grade=user.grade or "",
- mistakes=mistake_dicts,
- include_answers=data.include_answers,
- output_path=output_path,
- )
+ if HAS_HTML2PDF:
+ generate_html_exam_pdf(
+ title=data.title,
+ student_name=user.display_name or user.username,
+ grade=user.grade or "",
+ mistakes=mistake_dicts,
+ include_answers=data.include_answers,
+ output_path=output_path,
+ )
+ else:
+ generate_exam_pdf(
+ title=data.title,
+ student_name=user.display_name or user.username,
+ grade=user.grade or "",
+ mistakes=mistake_dicts,
+ include_answers=data.include_answers,
+ output_path=output_path,
+ )
# Save record
exam = ExamPaper(
diff --git a/backend/app/routers/ocr.py b/backend/app/routers/ocr.py
index c718580..8cb62d5 100644
--- a/backend/app/routers/ocr.py
+++ b/backend/app/routers/ocr.py
@@ -62,7 +62,9 @@ async def ocr_mistake(
ocr_result = all_ocr_results[0]
if len(all_ocr_results) > 1:
combined_text = "\n".join(r.get("question_text", "") for r in all_ocr_results if r.get("question_text"))
+ combined_html = "
".join(r.get("question_html", "") for r in all_ocr_results if r.get("question_html"))
ocr_result["question_text"] = combined_text
+ ocr_result["question_html"] = combined_html
# Create mistake record
mistake = Mistake(
@@ -70,6 +72,7 @@ async def ocr_mistake(
subject=ocr_result.get("subject") or subject or "数学",
question_type=ocr_result.get("question_type"),
question_text=ocr_result.get("question_text") or "识别失败,请手动输入",
+ question_html=ocr_result.get("question_html"),
correct_answer=ocr_result.get("correct_answer"),
student_answer=ocr_result.get("student_answer"),
error_analysis=ocr_result.get("error_analysis"),
diff --git a/backend/app/schemas/mistake.py b/backend/app/schemas/mistake.py
index aae84d2..e7cde5c 100644
--- a/backend/app/schemas/mistake.py
+++ b/backend/app/schemas/mistake.py
@@ -36,6 +36,7 @@ class MistakeResponse(BaseModel):
question_type: Optional[str]
grade_level: Optional[str]
question_text: str
+ question_html: Optional[str] = None
correct_answer: Optional[str]
student_answer: Optional[str]
error_analysis: Optional[str]
diff --git a/backend/app/services/html_pdf_service.py b/backend/app/services/html_pdf_service.py
new file mode 100644
index 0000000..92c740d
--- /dev/null
+++ b/backend/app/services/html_pdf_service.py
@@ -0,0 +1,249 @@
+"""HTML-based PDF generation using xhtml2pdf + AI-generated question HTML."""
+from __future__ import annotations
+import os
+import hashlib
+from datetime import date
+
+# Fix reportlab 4.x + Python 3.8 compatibility (must be before xhtml2pdf import)
+_original_md5 = hashlib.md5
+def _patched_md5(*args, **kwargs):
+ kwargs.pop("usedforsecurity", None)
+ return _original_md5(*args, **kwargs)
+hashlib.md5 = _patched_md5
+
+try:
+ from xhtml2pdf import pisa
+ HAS_HTML2PDF = True
+except ImportError:
+ HAS_HTML2PDF = False
+
+from app.config import settings
+
+# Chinese font stack
+FONT_FAMILY = '"Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif'
+
+HTML_TEMPLATE = """
+
+
{q.get("question_text", "")}
' + + # Add original images below the question + img_html = _get_image_html(q) + + student_ans = q.get("student_answer", "") + ans_note = f' (你的答案:{student_ans})' if student_ans else "" + + questions_html += f'''